feat: basic functionality implemented
Some checks failed
Lint / Run on Ubuntu (push) Failing after 2m58s
E2E Tests / Run on Ubuntu (push) Failing after 4m18s
Tests / Run on Ubuntu (push) Failing after 2m39s

- added Core CRD to manage DB migrations & configuration, PostgREST and
  GoTrue (auth)
- added APIGateway CRD to manage Envoy proxy
- added Dashboard CRD to manage (so far) pg-meta and (soon) studio
  deployments
- implemented basic Envoy control plane based on K8s watcher
This commit is contained in:
Peter 2025-01-04 17:07:49 +01:00
parent 2fae578618
commit 647f602c79
Signed by: prskr
GPG key ID: F56BED6903BC5E37
123 changed files with 12173 additions and 581 deletions

View file

@ -1,3 +1,7 @@
# More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file
# Ignore build and test binaries. # Ignore build and test binaries.
bin/ .idea
.devcontainer
.github
.zed
hack

3
.gitignore vendored
View file

@ -1 +1,4 @@
bin/ bin/
out/
.idea/
.venv/

View file

@ -28,6 +28,7 @@ linters:
- gofmt - gofmt
- goimports - goimports
- gosimple - gosimple
- godox
- govet - govet
- ineffassign - ineffassign
- lll - lll

View file

@ -1,33 +1,38 @@
# Build the manager binary # Build the manager binary
FROM golang:1.22 AS builder FROM golang:1.23.4 AS builder
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
WORKDIR /workspace WORKDIR /workspace
# Copy the Go Modules manifests
COPY go.mod go.mod
COPY go.sum go.sum
# cache deps before building and copying source so that we don't need to re-download as much # cache deps before building and copying source so that we don't need to re-download as much
# and so that source changes don't invalidate our downloaded layer # and so that source changes don't invalidate our downloaded layer
RUN go mod download RUN --mount=type=bind,source=go.mod,target=go.mod \
--mount=type=bind,source=go.sum,target=go.sum \
go mod download
# Copy the go source # Copy the go source
COPY cmd/main.go cmd/main.go COPY [ "go.*", "./" ]
COPY api/ api/ COPY [ "api", "api" ]
COPY internal/ internal/ COPY [ "assets/migrations", "assets/migrations" ]
COPY [ "cmd", "cmd" ]
COPY [ "infrastructure", "infrastructure" ]
COPY [ "internal", "internal" ]
COPY [ "magefiles", "magefiles" ]
COPY [ "tools", "tools" ]
# Build # Build
# the GOARCH has not a default value to allow the binary be built according to the host where the command # the GOARCH has not a default value to allow the binary be built according to the host where the command
# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o supabase-operator ./cmd/
# Use distroless as minimal base image to package the manager binary # Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details # Refer to https://github.com/GoogleContainerTools/distroless for more details
FROM gcr.io/distroless/static:nonroot FROM gcr.io/distroless/static:nonroot
WORKDIR / WORKDIR /
COPY --from=builder /workspace/manager . COPY --from=builder /workspace/supabase-operator .
USER 65532:65532 USER 65532:65532
ENTRYPOINT ["/manager"] ENTRYPOINT ["/supabase-operator"]

26
PROJECT
View file

@ -17,4 +17,30 @@ resources:
kind: Core kind: Core
path: code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1 path: code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1
version: v1alpha1 version: v1alpha1
webhooks:
defaulting: true
validation: true
webhookVersion: v1
- api:
crdVersion: v1
namespaced: true
controller: true
domain: k8s.icb4dc0.de
group: supabase
kind: APIGateway
path: code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1
version: v1alpha1
webhooks:
defaulting: true
validation: true
webhookVersion: v1
- api:
crdVersion: v1
namespaced: true
controller: true
domain: k8s.icb4dc0.de
group: supabase
kind: Dashboard
path: code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1
version: v1alpha1
version: "3" version: "3"

69
Tiltfile Normal file
View file

@ -0,0 +1,69 @@
# -*- mode: Python -*-
load('ext://restart_process', 'docker_build_with_restart')
allow_k8s_contexts('kind-kind')
local('./dev/prepare-dev-cluster.sh')
k8s_yaml(kustomize('config/dev'))
k8s_yaml(kustomize('config/samples'))
compile_cmd = 'CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/supabase-operator ./cmd/'
local_resource(
'manager-go-compile',
compile_cmd,
deps=[
'./api',
'./cmd',
'./assets',
'./infrastructure',
'./internal',
'./go.mod',
'./go.sum'
],
resource_deps=[]
)
docker_build_with_restart(
'supabase-operator',
'.',
entrypoint=['/app/bin/supabase-operator'],
dockerfile='dev/Dockerfile',
only=[
'./out',
],
live_update=[
sync('./out', '/app/bin'),
],
)
k8s_resource('supabase-controller-manager')
k8s_resource(
workload='supabase-control-plane',
port_forwards=18000,
)
k8s_resource(
objects=["cluster-example:Cluster:supabase-demo"],
new_name='Postgres cluster',
port_forwards=5432
)
k8s_resource(
objects=["core-sample:Core:supabase-demo"],
new_name='Supabase Core',
resource_deps=[
'Postgres cluster',
'supabase-controller-manager'
],
)
k8s_resource(
objects=["core-sample:APIGateway:supabase-demo"],
extra_pod_selectors={"app.kubernetes.io/component": "api-gateway"},
port_forwards=[8000, 19000],
new_name='API Gateway',
resource_deps=[
'supabase-controller-manager'
],
)

13
api/common.go Normal file
View file

@ -0,0 +1,13 @@
package api
import (
"iter"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
type ObjectList[T metav1.Object] interface {
client.ObjectList
Iter() iter.Seq[T]
}

View file

@ -0,0 +1,95 @@
/*
Copyright 2024 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 v1alpha1
import (
"iter"
"maps"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func init() {
SchemeBuilder.Register(&APIGateway{}, &APIGatewayList{})
}
type ControlPlaneSpec struct {
// Host is the hostname of the envoy control plane endpoint
Host string `json:"host"`
// Port is the port number of the envoy control plane endpoint - typically this is 18000
// +kubebuilder:default=18000
// +kubebuilder:validation:Maximum=65535
Port uint16 `json:"port"`
}
type EnvoySpec struct {
// ControlPlane - configure the control plane where Envoy will retrieve its configuration from
ControlPlane *ControlPlaneSpec `json:"controlPlane"`
// WorkloadTemplate - customize the Envoy deployment
WorkloadTemplate *WorkloadTemplate `json:"workloadTemplate,omitempty"`
}
// APIGatewaySpec defines the desired state of APIGateway.
type APIGatewaySpec struct {
// Envoy - configure the envoy instance and most importantly the control-plane
Envoy *EnvoySpec `json:"envoy"`
// JWKSSelector - selector where the JWKS can be retrieved from to enable the API gateway to validate JWTs
JWKSSelector *corev1.SecretKeySelector `json:"jwks"`
}
// APIGatewayStatus defines the observed state of APIGateway.
type APIGatewayStatus struct{}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// APIGateway is the Schema for the apigateways API.
type APIGateway struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec APIGatewaySpec `json:"spec,omitempty"`
Status APIGatewayStatus `json:"status,omitempty"`
}
func (g APIGateway) JwksSecretMeta() metav1.ObjectMeta {
return metav1.ObjectMeta{
Name: g.Spec.JWKSSelector.Name,
Namespace: g.Namespace,
Labels: maps.Clone(g.Labels),
}
}
// +kubebuilder:object:root=true
// APIGatewayList contains a list of APIGateway.
type APIGatewayList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []APIGateway `json:"items"`
}
func (l APIGatewayList) Iter() iter.Seq[*APIGateway] {
return func(yield func(*APIGateway) bool) {
for _, gw := range l.Items {
if !yield(&gw) {
return
}
}
}
}

View file

@ -0,0 +1,44 @@
/*
Copyright 2024 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 v1alpha1
import (
corev1 "k8s.io/api/core/v1"
)
type ImageSpec struct {
Image string `json:"image,omitempty"`
PullPolicy corev1.PullPolicy `json:"pullPolicy,omitempty"`
}
type ContainerTemplate struct {
ImageSpec `json:",inline"`
ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`
// SecurityContext -
SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"`
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
VolumeMounts []corev1.VolumeMount `json:"volumeMounts,omitempty"`
AdditionalEnv []corev1.EnvVar `json:"additionalEnv,omitempty"`
}
type WorkloadTemplate struct {
Replicas *int32 `json:"replicas,omitempty"`
SecurityContext *corev1.PodSecurityContext `json:"securityContext"`
AdditionalLabels map[string]string `json:"additionalLabels,omitempty"`
// Workload - customize the container template of the workload
Workload *ContainerTemplate `json:"workload,omitempty"`
}

View file

@ -19,46 +19,366 @@ package v1alpha1
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"iter"
"path"
"slices"
"strconv"
"strings"
"time" "time"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
) )
func init() {
SchemeBuilder.Register(&Core{}, &CoreList{})
}
var ErrNoSuchSecretValue = errors.New("no such secret value")
type DatabaseRolesSecrets struct {
Admin *corev1.LocalObjectReference `json:"supabaseAdmin,omitempty"`
Authenticator *corev1.LocalObjectReference `json:"authenticator,omitempty"`
AuthAdmin *corev1.LocalObjectReference `json:"supabaseAuthAdmin,omitempty"`
FunctionsAdmin *corev1.LocalObjectReference `json:"supabaseFunctionsAdmin,omitempty"`
StorageAdmin *corev1.LocalObjectReference `json:"supabaseStorageAdmin,omitempty"`
}
type DatabaseRoles struct {
// SelfManaged - whether the database roles are managed externally
// when enabled the operator does not attempt to create secrets, generate passwords or whatsoever for all database roles
// i.e. all secrets need to be provided or the instance won't work
SelfManaged bool `json:"selfManaged,omitempty"`
// Secrets - typed 'map' of secrets for each database role that Supabase needs
Secrets DatabaseRolesSecrets `json:"secrets,omitempty"`
}
type Database struct { type Database struct {
DSN *string `json:"dsn,omitempty"` DSN *string `json:"dsn,omitempty"`
DSNFrom *corev1.SecretKeySelector `json:"dsnFrom,omitempty"` DSNSecretRef *corev1.SecretKeySelector `json:"dsnFrom,omitempty"`
Roles DatabaseRoles `json:"roles,omitempty"`
} }
func (d Database) GetDSN(ctx context.Context, client client.Client) (string, error) { func (d Database) GetDSN(ctx context.Context, client client.Client) (string, error) {
if d.DSN != nil { if d.DSNSecretRef == nil {
return *d.DSN, nil
}
if d.DSNFrom == nil {
return "", errors.New("DSN not set") return "", errors.New("DSN not set")
} }
var secret corev1.Secret var secret corev1.Secret
if err := client.Get(ctx, types.NamespacedName{Name: d.DSNFrom.Name}, &secret); err != nil { if err := client.Get(ctx, types.NamespacedName{Name: d.DSNSecretRef.Name}, &secret); err != nil {
return "", err return "", err
} }
data, ok := secret.Data[d.DSNFrom.Key] data, ok := secret.Data[d.DSNSecretRef.Key]
if !ok { if !ok {
return "", errors.New("key not found in secret") return "", fmt.Errorf("%w: %s", ErrNoSuchSecretValue, d.DSNSecretRef.Key)
} }
return string(data), nil return string(data), nil
} }
func (d Database) DSNEnv(key string) corev1.EnvVar {
return corev1.EnvVar{
Name: key,
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: d.DSNSecretRef,
},
}
}
type JwtSpec struct {
// Secret - JWT HMAC secret in plain text
// This is WRITE-ONLY and will be copied to the SecretRef by the defaulter
Secret *string `json:"secret,omitempty"`
// SecretRef - object reference to the Secret where JWT values are stored
SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"`
// SecretKey - key in secret where to read the JWT HMAC secret from
// +kubebuilder:default=secret
SecretKey string `json:"secretKey,omitempty"`
// JwksKey - key in secret where to read the JWKS from
// +kubebuilder:default=jwks.json
JwksKey string `json:"jwksKey,omitempty"`
// AnonKey - key in secret where to read the anon JWT from
// +kubebuilder:default=anon_key
AnonKey string `json:"anonKey,omitempty"`
// ServiceKey - key in secret where to read the service JWT from
// +kubebuilder:default=service_key
ServiceKey string `json:"serviceKey,omitempty"`
// Expiry - expiration time in seconds for JWTs
// +kubebuilder:default=3600
Expiry int `json:"expiry,omitempty"`
}
func (s JwtSpec) GetJWTSecret(ctx context.Context, client client.Client) ([]byte, error) {
var secret corev1.Secret
if err := client.Get(ctx, types.NamespacedName{Name: s.SecretRef.Name}, &secret); err != nil {
return nil, nil
}
value, ok := secret.Data[s.SecretKey]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrNoSuchSecretValue, s.SecretKey)
}
return value, nil
}
func (s JwtSpec) SecretKeySelector() *corev1.SecretKeySelector {
return &corev1.SecretKeySelector{
LocalObjectReference: *s.SecretRef,
Key: s.SecretKey,
}
}
func (s JwtSpec) JwksKeySelector() *corev1.SecretKeySelector {
return &corev1.SecretKeySelector{
LocalObjectReference: *s.SecretRef,
Key: s.JwksKey,
}
}
func (s JwtSpec) SecretAsEnv(key string) corev1.EnvVar {
return corev1.EnvVar{
Name: key,
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: *s.SecretRef,
Key: s.SecretKey,
},
},
}
}
func (s JwtSpec) ExpiryAsEnv(key string) corev1.EnvVar {
return corev1.EnvVar{
Name: key,
Value: strconv.Itoa(s.Expiry),
}
}
type PostgrestSpec struct {
// Schemas - schema where PostgREST is looking for objects (tables, views, functions, ...)
// +kubebuilder:default={"public","graphql_public"}
Schemas []string `json:"schemas,omitempty"`
// ExtraSearchPath - Extra schemas to add to the search_path of every request.
// These schemas tables, views and functions dont get API endpoints, they can only be referred from the database objects inside your db-schemas.
// +kubebuilder:default={"public","extensions"}
ExtraSearchPath []string `json:"extraSearchPath,omitempty"`
// AnonRole - name of the anon role
// +kubebuilder:default=anon
AnonRole string `json:"anonRole,omitempty"`
// MaxRows - maximum number of rows PostgREST will load at a time
// +kubebuilder:default=1000
MaxRows int `json:"maxRows,omitempty"`
// WorkloadTemplate - customize the PostgREST workload
WorkloadTemplate *WorkloadTemplate `json:"workloadTemplate,omitempty"`
}
type AuthProviderMeta struct {
// Enabled - whether the authentication provider is enabled or not
Enabled bool `json:"enabled,omitempty"`
}
func (p *AuthProviderMeta) Vars(provider string) []corev1.EnvVar {
if p == nil {
return nil
}
return []corev1.EnvVar{{
Name: fmt.Sprintf("GOTRUE_EXTERNAL_%s_ENABLED", strings.ToUpper(provider)),
Value: strconv.FormatBool(p.Enabled),
}}
}
type EmailAuthSmtpSpec struct {
Host string `json:"host"`
Port uint16 `json:"port"`
MaxFrequency *uint `json:"maxFrequency,omitempty"`
CredentialsFrom *corev1.LocalObjectReference `json:"credentialsFrom"`
}
type EmailAuthProvider struct {
AuthProviderMeta `json:",inline"`
AdminEmail string `json:"adminEmail"`
SenderName *string `json:"senderName,omitempty"`
Autoconfirm *bool `json:"autoconfirmEmail,omitempty"`
SubjectsInvite string `json:"subjectsInvite,omitempty"`
SubjectsConfirmation string `json:"subjectsConfirmation,omitempty"`
SmtpSpec *EmailAuthSmtpSpec `json:"smtpSpec"`
}
func (p *EmailAuthProvider) Vars(apiExternalURL string) []corev1.EnvVar {
if p == nil || p.SmtpSpec == nil {
return nil
}
svcDefaults := supabase.ServiceConfig.Auth.Defaults
vars := []corev1.EnvVar{
{Name: "GOTRUE_SMTP_HOST", Value: p.SmtpSpec.Host},
{Name: "GOTRUE_SMTP_PORT", Value: strconv.FormatUint(uint64(p.SmtpSpec.Port), 10)},
{
Name: "GOTRUE_SMTP_USER",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: *p.SmtpSpec.CredentialsFrom,
Key: corev1.BasicAuthUsernameKey,
},
},
},
{
Name: "GOTRUE_SMTP_PASS",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: *p.SmtpSpec.CredentialsFrom,
Key: corev1.BasicAuthPasswordKey,
},
},
},
{Name: "GOTRUE_SMTP_ADMIN_EMAIL", Value: p.AdminEmail},
{Name: "MAILER_URLPATHS_INVITE", Value: path.Join(apiExternalURL, svcDefaults.MailerUrlPathsInvite)},
{Name: "MAILER_URLPATHS_CONFIRMATION", Value: path.Join(apiExternalURL, svcDefaults.MailerUrlPathsConfirmation)},
{Name: "MAILER_URLPATHS_RECOVERY", Value: path.Join(apiExternalURL, svcDefaults.MailerUrlPathsRecovery)},
{Name: "MAILER_URLPATHS_EMAIL_CHANGE", Value: path.Join(apiExternalURL, svcDefaults.MailerUrlPathsEmailChange)},
}
if p.SubjectsInvite != "" {
vars = append(vars, corev1.EnvVar{Name: "MAILER_SUBJECTS_INVITE", Value: p.SubjectsInvite})
}
return vars
}
type PhoneAuthProvider struct {
AuthProviderMeta `json:",inline"`
}
func (p *PhoneAuthProvider) Vars() []corev1.EnvVar {
if p == nil {
return nil
}
return []corev1.EnvVar{}
}
type OAuthProvider struct {
ClientID string `json:"clientID"`
ClientSecretRef *corev1.SecretKeySelector `json:"clientSecretRef"`
URL string `json:"url,omitempty"`
}
func (p *OAuthProvider) Vars(provider, apiExternalURL string) []corev1.EnvVar {
if p == nil {
return nil
}
vars := []corev1.EnvVar{
{
Name: fmt.Sprintf("GOTRUE_EXTERNAL_%s_CLIENT_ID", strings.ToUpper(provider)),
Value: p.ClientID,
},
{
Name: fmt.Sprintf("GOTRUE_EXTERNAL_%s_REDIRECT_URI", strings.ToUpper(provider)),
Value: path.Join(apiExternalURL, "/auth/v1/callback"),
},
{
Name: fmt.Sprintf("GOTRUE_EXTERNAL_%s_SECRET", strings.ToUpper(provider)),
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: p.ClientSecretRef,
},
},
}
if p.URL != "" {
vars = append(vars, corev1.EnvVar{
Name: fmt.Sprintf("GOTRUE_EXTERNAL_%s_URL", strings.ToUpper(provider)),
Value: p.URL,
})
}
return vars
}
type AzureAuthProvider struct {
AuthProviderMeta `json:",inline"`
OAuthProvider `json:",inline"`
}
func (p *AzureAuthProvider) Vars(apiExternalURL string) []corev1.EnvVar {
const providerName = "AZURE"
if p == nil {
return nil
}
return slices.Concat(
p.AuthProviderMeta.Vars(providerName),
p.OAuthProvider.Vars(providerName, apiExternalURL),
)
}
type GithubAuthProvider struct {
AuthProviderMeta `json:",inline"`
OAuthProvider `json:",inline"`
}
func (p *GithubAuthProvider) Vars(apiExternalURL string) []corev1.EnvVar {
const providerName = "GITHUB"
if p == nil {
return nil
}
return slices.Concat(
p.AuthProviderMeta.Vars(providerName),
p.OAuthProvider.Vars(providerName, apiExternalURL),
)
}
type AuthProviders struct {
Email *EmailAuthProvider `json:"email,omitempty"`
Azure *AzureAuthProvider `json:"azure,omitempty"`
Github *GithubAuthProvider `json:"github,omitempty"`
Phone *PhoneAuthProvider `json:"phone,omitempty"`
}
func (p *AuthProviders) Vars(apiExternalURL string) []corev1.EnvVar {
if p == nil {
return nil
}
return slices.Concat(
p.Email.Vars(apiExternalURL),
p.Azure.Vars(apiExternalURL),
p.Github.Vars(apiExternalURL),
p.Phone.Vars(),
)
}
type AuthSpec struct {
// APIExternalURL is referring to the URL where Supabase API will be available
// Typically this is the ingress of the API gateway
APIExternalURL string `json:"externalUrl"`
// SiteURL is referring to the URL of the (frontend) application
// In most Kubernetes scenarios this is the same as the APIExternalURL with a different path handler in the ingress
SiteURL string `json:"siteUrl"`
AdditionalRedirectUrls []string `json:"additionalRedirectUrls,omitempty"`
DisableSignup *bool `json:"disableSignup,omitempty"`
AnonymousUsersEnabled *bool `json:"anonymousUsersEnabled,omitempty"`
Providers *AuthProviders `json:"providers,omitempty"`
WorkloadTemplate *WorkloadTemplate `json:"workloadTemplate,omitempty"`
EmailSignupDisabled *bool `json:"emailSignupDisabled,omitempty"`
}
// CoreSpec defines the desired state of Core. // CoreSpec defines the desired state of Core.
type CoreSpec struct { type CoreSpec struct {
// Important: Run "make" to regenerate code after modifying this file JWT *JwtSpec `json:"jwt,omitempty"`
Database Database `json:"database,omitempty"`
Database Database `json:"database,omitempty"` Postgrest PostgrestSpec `json:"postgrest,omitempty"`
Auth *AuthSpec `json:"auth,omitempty"`
} }
type MigrationStatus map[string]int64 type MigrationStatus map[string]int64
@ -72,11 +392,26 @@ func (s MigrationStatus) Record(name string) {
s[name] = time.Now().UTC().UnixMilli() s[name] = time.Now().UTC().UnixMilli()
} }
type DatabaseStatus struct {
AppliedMigrations MigrationStatus `json:"appliedMigrations,omitempty"`
Roles map[string][]byte `json:"roles,omitempty"`
}
type CoreConditionType string
type CoreCondition struct {
Type CoreConditionType `json:"type"`
Status corev1.ConditionStatus `json:"status"`
LastProbeTime metav1.Time `json:"lastProbeTime,omitempty"`
LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
}
// CoreStatus defines the observed state of Core. // CoreStatus defines the observed state of Core.
type CoreStatus struct { type CoreStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster Database DatabaseStatus `json:"database,omitempty"`
// Important: Run "make" to regenerate code after modifying this file Conditions []CoreCondition `json:"conditions,omitempty"`
AppliedMigrations MigrationStatus `json:"appliedMigrations,omitempty"`
} }
// +kubebuilder:object:root=true // +kubebuilder:object:root=true
@ -100,6 +435,12 @@ type CoreList struct {
Items []Core `json:"items"` Items []Core `json:"items"`
} }
func init() { func (l CoreList) Iter() iter.Seq[*Core] {
SchemeBuilder.Register(&Core{}, &CoreList{}) return func(yield func(*Core) bool) {
for _, c := range l.Items {
if !yield(&c) {
return
}
}
}
} }

View file

@ -0,0 +1,96 @@
/*
Copyright 2024 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 v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type StudioSpec struct {
// WorkloadTemplate - customize the studio deployment
WorkloadTemplate *WorkloadTemplate `json:"workloadTemplate,omitempty"`
}
type PGMetaSpec struct {
// WorkloadTemplate - customize the pg-meta deployment
WorkloadTemplate *WorkloadTemplate `json:"workloadTemplate,omitempty"`
}
type DashboardDbSpec struct {
Host string `json:"host"`
// Port - Database port, typically 5432
// +kubebuilder:default=5432
Port int `json:"port,omitempty"`
DBName string `json:"dbName"`
// DBCredentialsRef - reference to a Secret key where the DB credentials can be retrieved from
// Credentials need to be stored in basic auth form
DBCredentialsRef *corev1.LocalObjectReference `json:"dbCredentialsRef"`
}
func (s DashboardDbSpec) UserRef() *corev1.SecretKeySelector {
return &corev1.SecretKeySelector{
LocalObjectReference: *s.DBCredentialsRef,
Key: corev1.BasicAuthUsernameKey,
}
}
func (s DashboardDbSpec) PasswordRef() *corev1.SecretKeySelector {
return &corev1.SecretKeySelector{
LocalObjectReference: *s.DBCredentialsRef,
Key: corev1.BasicAuthPasswordKey,
}
}
// DashboardSpec defines the desired state of Dashboard.
type DashboardSpec struct {
DBSpec *DashboardDbSpec `json:"db"`
// PGMeta
// +kubebuilder:default={}
PGMeta *PGMetaSpec `json:"pgMeta,omitempty"`
// Studio
// +kubebuilder:default={}
Studio *StudioSpec `json:"studio,omitempty"`
}
// DashboardStatus defines the observed state of Dashboard.
type DashboardStatus struct{}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// Dashboard is the Schema for the dashboards API.
type Dashboard struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DashboardSpec `json:"spec,omitempty"`
Status DashboardStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// DashboardList contains a list of Dashboard.
type DashboardList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Dashboard `json:"items"`
}
func init() {
SchemeBuilder.Register(&Dashboard{}, &DashboardList{})
}

View file

@ -21,15 +21,283 @@ limitations under the License.
package v1alpha1 package v1alpha1
import ( import (
"k8s.io/api/core/v1"
runtime "k8s.io/apimachinery/pkg/runtime" runtime "k8s.io/apimachinery/pkg/runtime"
) )
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *APIGateway) DeepCopyInto(out *APIGateway) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
out.Status = in.Status
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIGateway.
func (in *APIGateway) DeepCopy() *APIGateway {
if in == nil {
return nil
}
out := new(APIGateway)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *APIGateway) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *APIGatewayList) DeepCopyInto(out *APIGatewayList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]APIGateway, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIGatewayList.
func (in *APIGatewayList) DeepCopy() *APIGatewayList {
if in == nil {
return nil
}
out := new(APIGatewayList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *APIGatewayList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *APIGatewaySpec) DeepCopyInto(out *APIGatewaySpec) {
*out = *in
if in.Envoy != nil {
in, out := &in.Envoy, &out.Envoy
*out = new(EnvoySpec)
(*in).DeepCopyInto(*out)
}
if in.JWKSSelector != nil {
in, out := &in.JWKSSelector, &out.JWKSSelector
*out = new(v1.SecretKeySelector)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIGatewaySpec.
func (in *APIGatewaySpec) DeepCopy() *APIGatewaySpec {
if in == nil {
return nil
}
out := new(APIGatewaySpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *APIGatewayStatus) DeepCopyInto(out *APIGatewayStatus) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIGatewayStatus.
func (in *APIGatewayStatus) DeepCopy() *APIGatewayStatus {
if in == nil {
return nil
}
out := new(APIGatewayStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AuthProviderMeta) DeepCopyInto(out *AuthProviderMeta) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthProviderMeta.
func (in *AuthProviderMeta) DeepCopy() *AuthProviderMeta {
if in == nil {
return nil
}
out := new(AuthProviderMeta)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AuthProviders) DeepCopyInto(out *AuthProviders) {
*out = *in
if in.Email != nil {
in, out := &in.Email, &out.Email
*out = new(EmailAuthProvider)
(*in).DeepCopyInto(*out)
}
if in.Azure != nil {
in, out := &in.Azure, &out.Azure
*out = new(AzureAuthProvider)
(*in).DeepCopyInto(*out)
}
if in.Github != nil {
in, out := &in.Github, &out.Github
*out = new(GithubAuthProvider)
(*in).DeepCopyInto(*out)
}
if in.Phone != nil {
in, out := &in.Phone, &out.Phone
*out = new(PhoneAuthProvider)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthProviders.
func (in *AuthProviders) DeepCopy() *AuthProviders {
if in == nil {
return nil
}
out := new(AuthProviders)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AuthSpec) DeepCopyInto(out *AuthSpec) {
*out = *in
if in.AdditionalRedirectUrls != nil {
in, out := &in.AdditionalRedirectUrls, &out.AdditionalRedirectUrls
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.DisableSignup != nil {
in, out := &in.DisableSignup, &out.DisableSignup
*out = new(bool)
**out = **in
}
if in.AnonymousUsersEnabled != nil {
in, out := &in.AnonymousUsersEnabled, &out.AnonymousUsersEnabled
*out = new(bool)
**out = **in
}
if in.Providers != nil {
in, out := &in.Providers, &out.Providers
*out = new(AuthProviders)
(*in).DeepCopyInto(*out)
}
if in.WorkloadTemplate != nil {
in, out := &in.WorkloadTemplate, &out.WorkloadTemplate
*out = new(WorkloadTemplate)
(*in).DeepCopyInto(*out)
}
if in.EmailSignupDisabled != nil {
in, out := &in.EmailSignupDisabled, &out.EmailSignupDisabled
*out = new(bool)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthSpec.
func (in *AuthSpec) DeepCopy() *AuthSpec {
if in == nil {
return nil
}
out := new(AuthSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AzureAuthProvider) DeepCopyInto(out *AzureAuthProvider) {
*out = *in
out.AuthProviderMeta = in.AuthProviderMeta
in.OAuthProvider.DeepCopyInto(&out.OAuthProvider)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AzureAuthProvider.
func (in *AzureAuthProvider) DeepCopy() *AzureAuthProvider {
if in == nil {
return nil
}
out := new(AzureAuthProvider)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ContainerTemplate) DeepCopyInto(out *ContainerTemplate) {
*out = *in
out.ImageSpec = in.ImageSpec
if in.ImagePullSecrets != nil {
in, out := &in.ImagePullSecrets, &out.ImagePullSecrets
*out = make([]v1.LocalObjectReference, len(*in))
copy(*out, *in)
}
if in.SecurityContext != nil {
in, out := &in.SecurityContext, &out.SecurityContext
*out = new(v1.SecurityContext)
(*in).DeepCopyInto(*out)
}
in.Resources.DeepCopyInto(&out.Resources)
if in.VolumeMounts != nil {
in, out := &in.VolumeMounts, &out.VolumeMounts
*out = make([]v1.VolumeMount, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.AdditionalEnv != nil {
in, out := &in.AdditionalEnv, &out.AdditionalEnv
*out = make([]v1.EnvVar, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerTemplate.
func (in *ContainerTemplate) DeepCopy() *ContainerTemplate {
if in == nil {
return nil
}
out := new(ContainerTemplate)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ControlPlaneSpec) DeepCopyInto(out *ControlPlaneSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlPlaneSpec.
func (in *ControlPlaneSpec) DeepCopy() *ControlPlaneSpec {
if in == nil {
return nil
}
out := new(ControlPlaneSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Core) DeepCopyInto(out *Core) { func (in *Core) DeepCopyInto(out *Core) {
*out = *in *out = *in
out.TypeMeta = in.TypeMeta out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status) in.Status.DeepCopyInto(&out.Status)
} }
@ -51,6 +319,23 @@ func (in *Core) DeepCopyObject() runtime.Object {
return nil return nil
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CoreCondition) DeepCopyInto(out *CoreCondition) {
*out = *in
in.LastProbeTime.DeepCopyInto(&out.LastProbeTime)
in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CoreCondition.
func (in *CoreCondition) DeepCopy() *CoreCondition {
if in == nil {
return nil
}
out := new(CoreCondition)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CoreList) DeepCopyInto(out *CoreList) { func (in *CoreList) DeepCopyInto(out *CoreList) {
*out = *in *out = *in
@ -86,7 +371,18 @@ func (in *CoreList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CoreSpec) DeepCopyInto(out *CoreSpec) { func (in *CoreSpec) DeepCopyInto(out *CoreSpec) {
*out = *in *out = *in
out.Database = in.Database if in.JWT != nil {
in, out := &in.JWT, &out.JWT
*out = new(JwtSpec)
(*in).DeepCopyInto(*out)
}
in.Database.DeepCopyInto(&out.Database)
in.Postgrest.DeepCopyInto(&out.Postgrest)
if in.Auth != nil {
in, out := &in.Auth, &out.Auth
*out = new(AuthSpec)
(*in).DeepCopyInto(*out)
}
} }
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CoreSpec. // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CoreSpec.
@ -102,11 +398,12 @@ func (in *CoreSpec) DeepCopy() *CoreSpec {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CoreStatus) DeepCopyInto(out *CoreStatus) { func (in *CoreStatus) DeepCopyInto(out *CoreStatus) {
*out = *in *out = *in
if in.AppliedMigrations != nil { in.Database.DeepCopyInto(&out.Database)
in, out := &in.AppliedMigrations, &out.AppliedMigrations if in.Conditions != nil {
*out = make(map[string]uint64, len(*in)) in, out := &in.Conditions, &out.Conditions
for key, val := range *in { *out = make([]CoreCondition, len(*in))
(*out)[key] = val for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
} }
} }
} }
@ -121,9 +418,144 @@ func (in *CoreStatus) DeepCopy() *CoreStatus {
return out return out
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Dashboard) DeepCopyInto(out *Dashboard) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
out.Status = in.Status
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Dashboard.
func (in *Dashboard) DeepCopy() *Dashboard {
if in == nil {
return nil
}
out := new(Dashboard)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *Dashboard) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// 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
if in.DBCredentialsRef != nil {
in, out := &in.DBCredentialsRef, &out.DBCredentialsRef
*out = new(v1.LocalObjectReference)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardDbSpec.
func (in *DashboardDbSpec) DeepCopy() *DashboardDbSpec {
if in == nil {
return nil
}
out := new(DashboardDbSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardList) DeepCopyInto(out *DashboardList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Dashboard, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardList.
func (in *DashboardList) DeepCopy() *DashboardList {
if in == nil {
return nil
}
out := new(DashboardList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *DashboardList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// 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
if in.DBSpec != nil {
in, out := &in.DBSpec, &out.DBSpec
*out = new(DashboardDbSpec)
(*in).DeepCopyInto(*out)
}
if in.PGMeta != nil {
in, out := &in.PGMeta, &out.PGMeta
*out = new(PGMetaSpec)
(*in).DeepCopyInto(*out)
}
if in.Studio != nil {
in, out := &in.Studio, &out.Studio
*out = new(StudioSpec)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSpec.
func (in *DashboardSpec) DeepCopy() *DashboardSpec {
if in == nil {
return nil
}
out := new(DashboardSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardStatus) DeepCopyInto(out *DashboardStatus) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardStatus.
func (in *DashboardStatus) DeepCopy() *DashboardStatus {
if in == nil {
return nil
}
out := new(DashboardStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Database) DeepCopyInto(out *Database) { func (in *Database) DeepCopyInto(out *Database) {
*out = *in *out = *in
if in.DSN != nil {
in, out := &in.DSN, &out.DSN
*out = new(string)
**out = **in
}
if in.DSNSecretRef != nil {
in, out := &in.DSNSecretRef, &out.DSNSecretRef
*out = new(v1.SecretKeySelector)
(*in).DeepCopyInto(*out)
}
in.Roles.DeepCopyInto(&out.Roles)
} }
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Database. // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Database.
@ -135,3 +567,399 @@ func (in *Database) DeepCopy() *Database {
in.DeepCopyInto(out) in.DeepCopyInto(out)
return out return out
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DatabaseRoles) DeepCopyInto(out *DatabaseRoles) {
*out = *in
in.Secrets.DeepCopyInto(&out.Secrets)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseRoles.
func (in *DatabaseRoles) DeepCopy() *DatabaseRoles {
if in == nil {
return nil
}
out := new(DatabaseRoles)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DatabaseRolesSecrets) DeepCopyInto(out *DatabaseRolesSecrets) {
*out = *in
if in.Admin != nil {
in, out := &in.Admin, &out.Admin
*out = new(v1.LocalObjectReference)
**out = **in
}
if in.Authenticator != nil {
in, out := &in.Authenticator, &out.Authenticator
*out = new(v1.LocalObjectReference)
**out = **in
}
if in.AuthAdmin != nil {
in, out := &in.AuthAdmin, &out.AuthAdmin
*out = new(v1.LocalObjectReference)
**out = **in
}
if in.FunctionsAdmin != nil {
in, out := &in.FunctionsAdmin, &out.FunctionsAdmin
*out = new(v1.LocalObjectReference)
**out = **in
}
if in.StorageAdmin != nil {
in, out := &in.StorageAdmin, &out.StorageAdmin
*out = new(v1.LocalObjectReference)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseRolesSecrets.
func (in *DatabaseRolesSecrets) DeepCopy() *DatabaseRolesSecrets {
if in == nil {
return nil
}
out := new(DatabaseRolesSecrets)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DatabaseStatus) DeepCopyInto(out *DatabaseStatus) {
*out = *in
if in.AppliedMigrations != nil {
in, out := &in.AppliedMigrations, &out.AppliedMigrations
*out = make(MigrationStatus, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Roles != nil {
in, out := &in.Roles, &out.Roles
*out = make(map[string][]byte, len(*in))
for key, val := range *in {
var outVal []byte
if val == nil {
(*out)[key] = nil
} else {
inVal := (*in)[key]
in, out := &inVal, &outVal
*out = make([]byte, len(*in))
copy(*out, *in)
}
(*out)[key] = outVal
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseStatus.
func (in *DatabaseStatus) DeepCopy() *DatabaseStatus {
if in == nil {
return nil
}
out := new(DatabaseStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EmailAuthProvider) DeepCopyInto(out *EmailAuthProvider) {
*out = *in
out.AuthProviderMeta = in.AuthProviderMeta
if in.SenderName != nil {
in, out := &in.SenderName, &out.SenderName
*out = new(string)
**out = **in
}
if in.Autoconfirm != nil {
in, out := &in.Autoconfirm, &out.Autoconfirm
*out = new(bool)
**out = **in
}
if in.SmtpSpec != nil {
in, out := &in.SmtpSpec, &out.SmtpSpec
*out = new(EmailAuthSmtpSpec)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmailAuthProvider.
func (in *EmailAuthProvider) DeepCopy() *EmailAuthProvider {
if in == nil {
return nil
}
out := new(EmailAuthProvider)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EmailAuthSmtpSpec) DeepCopyInto(out *EmailAuthSmtpSpec) {
*out = *in
if in.MaxFrequency != nil {
in, out := &in.MaxFrequency, &out.MaxFrequency
*out = new(uint)
**out = **in
}
if in.CredentialsFrom != nil {
in, out := &in.CredentialsFrom, &out.CredentialsFrom
*out = new(v1.LocalObjectReference)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmailAuthSmtpSpec.
func (in *EmailAuthSmtpSpec) DeepCopy() *EmailAuthSmtpSpec {
if in == nil {
return nil
}
out := new(EmailAuthSmtpSpec)
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
if in.ControlPlane != nil {
in, out := &in.ControlPlane, &out.ControlPlane
*out = new(ControlPlaneSpec)
**out = **in
}
if in.WorkloadTemplate != nil {
in, out := &in.WorkloadTemplate, &out.WorkloadTemplate
*out = new(WorkloadTemplate)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvoySpec.
func (in *EnvoySpec) DeepCopy() *EnvoySpec {
if in == nil {
return nil
}
out := new(EnvoySpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GithubAuthProvider) DeepCopyInto(out *GithubAuthProvider) {
*out = *in
out.AuthProviderMeta = in.AuthProviderMeta
in.OAuthProvider.DeepCopyInto(&out.OAuthProvider)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GithubAuthProvider.
func (in *GithubAuthProvider) DeepCopy() *GithubAuthProvider {
if in == nil {
return nil
}
out := new(GithubAuthProvider)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ImageSpec) DeepCopyInto(out *ImageSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageSpec.
func (in *ImageSpec) DeepCopy() *ImageSpec {
if in == nil {
return nil
}
out := new(ImageSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JwtSpec) DeepCopyInto(out *JwtSpec) {
*out = *in
if in.Secret != nil {
in, out := &in.Secret, &out.Secret
*out = new(string)
**out = **in
}
if in.SecretRef != nil {
in, out := &in.SecretRef, &out.SecretRef
*out = new(v1.LocalObjectReference)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JwtSpec.
func (in *JwtSpec) DeepCopy() *JwtSpec {
if in == nil {
return nil
}
out := new(JwtSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in MigrationStatus) DeepCopyInto(out *MigrationStatus) {
{
in := &in
*out = make(MigrationStatus, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MigrationStatus.
func (in MigrationStatus) DeepCopy() MigrationStatus {
if in == nil {
return nil
}
out := new(MigrationStatus)
in.DeepCopyInto(out)
return *out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *OAuthProvider) DeepCopyInto(out *OAuthProvider) {
*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 OAuthProvider.
func (in *OAuthProvider) DeepCopy() *OAuthProvider {
if in == nil {
return nil
}
out := new(OAuthProvider)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PGMetaSpec) DeepCopyInto(out *PGMetaSpec) {
*out = *in
if in.WorkloadTemplate != nil {
in, out := &in.WorkloadTemplate, &out.WorkloadTemplate
*out = new(WorkloadTemplate)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGMetaSpec.
func (in *PGMetaSpec) DeepCopy() *PGMetaSpec {
if in == nil {
return nil
}
out := new(PGMetaSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PhoneAuthProvider) DeepCopyInto(out *PhoneAuthProvider) {
*out = *in
out.AuthProviderMeta = in.AuthProviderMeta
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PhoneAuthProvider.
func (in *PhoneAuthProvider) DeepCopy() *PhoneAuthProvider {
if in == nil {
return nil
}
out := new(PhoneAuthProvider)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PostgrestSpec) DeepCopyInto(out *PostgrestSpec) {
*out = *in
if in.Schemas != nil {
in, out := &in.Schemas, &out.Schemas
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.ExtraSearchPath != nil {
in, out := &in.ExtraSearchPath, &out.ExtraSearchPath
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.WorkloadTemplate != nil {
in, out := &in.WorkloadTemplate, &out.WorkloadTemplate
*out = new(WorkloadTemplate)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgrestSpec.
func (in *PostgrestSpec) DeepCopy() *PostgrestSpec {
if in == nil {
return nil
}
out := new(PostgrestSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *StudioSpec) DeepCopyInto(out *StudioSpec) {
*out = *in
if in.WorkloadTemplate != nil {
in, out := &in.WorkloadTemplate, &out.WorkloadTemplate
*out = new(WorkloadTemplate)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StudioSpec.
func (in *StudioSpec) DeepCopy() *StudioSpec {
if in == nil {
return nil
}
out := new(StudioSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WorkloadTemplate) DeepCopyInto(out *WorkloadTemplate) {
*out = *in
if in.Replicas != nil {
in, out := &in.Replicas, &out.Replicas
*out = new(int32)
**out = **in
}
if in.SecurityContext != nil {
in, out := &in.SecurityContext, &out.SecurityContext
*out = new(v1.PodSecurityContext)
(*in).DeepCopyInto(*out)
}
if in.AdditionalLabels != nil {
in, out := &in.AdditionalLabels, &out.AdditionalLabels
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Workload != nil {
in, out := &in.Workload, &out.Workload
*out = new(ContainerTemplate)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadTemplate.
func (in *WorkloadTemplate) DeepCopy() *WorkloadTemplate {
if in == nil {
return nil
}
out := new(WorkloadTemplate)
in.DeepCopyInto(out)
return out
}

View file

@ -2,6 +2,7 @@ package migrations
import ( import (
"embed" "embed"
"fmt"
"io/fs" "io/fs"
"iter" "iter"
"path" "path"
@ -25,8 +26,18 @@ func MigrationScripts() iter.Seq2[Script, error] {
return readScripts(path.Join(".", "migrations")) return readScripts(path.Join(".", "migrations"))
} }
func RoleCreationScript(roleName string) (Script, error) {
fileName := fmt.Sprintf("%s.sql", roleName)
content, err := migrationsFS.ReadFile(path.Join("roles", fileName))
if err != nil {
return Script{}, err
}
return Script{fileName, string(content)}, nil
}
func readScripts(dir string) iter.Seq2[Script, error] { func readScripts(dir string) iter.Seq2[Script, error] {
return iter.Seq2[Script, error](func(yield func(Script, error) bool) { return func(yield func(Script, error) bool) {
files, err := migrationsFS.ReadDir(dir) files, err := migrationsFS.ReadDir(dir)
if err != nil { if err != nil {
yield(Script{}, err) yield(Script{}, err)
@ -58,5 +69,5 @@ func readScripts(dir string) iter.Seq2[Script, error] {
return return
} }
} }
}) }
} }

View file

@ -0,0 +1,5 @@
create user supabase_functions_admin createrole noinherit;
alter user supabase_functions_admin
set
search_path = supabase_functions;

145
cmd/control_plane.go Normal file
View file

@ -0,0 +1,145 @@
/*
Copyright 2024 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 (
"context"
"errors"
"fmt"
"net"
"time"
"github.com/alecthomas/kong"
clusterservice "github.com/envoyproxy/go-control-plane/envoy/service/cluster/v3"
discoverygrpc "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3"
endpointservice "github.com/envoyproxy/go-control-plane/envoy/service/endpoint/v3"
listenerservice "github.com/envoyproxy/go-control-plane/envoy/service/listener/v3"
routeservice "github.com/envoyproxy/go-control-plane/envoy/service/route/v3"
runtimeservice "github.com/envoyproxy/go-control-plane/envoy/service/runtime/v3"
secretservice "github.com/envoyproxy/go-control-plane/envoy/service/secret/v3"
"github.com/envoyproxy/go-control-plane/pkg/cache/v3"
"github.com/envoyproxy/go-control-plane/pkg/server/v3"
"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"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"code.icb4dc0.de/prskr/supabase-operator/internal/controlplane"
)
type controlPlane struct {
ListenAddr string `name:"listen-address" default:":18000" help:"The address the control plane binds to."`
}
func (p controlPlane) Run(ctx context.Context, cache cache.SnapshotCache) (err error) {
const (
grpcKeepaliveTime = 30 * time.Second
grpcKeepaliveTimeout = 5 * time.Second
grpcKeepaliveMinTime = 30 * time.Second
grpcMaxConcurrentStreams = 1000000
)
logger := ctrl.Log.WithName("control-plane")
clientOpts := client.Options{
Scheme: scheme,
}
logger.Info("Creating client")
watcherClient, err := client.NewWithWatch(ctrl.GetConfigOrDie(), clientOpts)
if err != nil {
return err
}
srv := server.NewServer(ctx, cache, nil)
// gRPC golang library sets a very small upper bound for the number gRPC/h2
// streams over a single TCP connection. If a proxy multiplexes requests over
// a single connection to the management server, then it might lead to
// availability problems. Keepalive timeouts based on connection_keepalive parameter https://www.envoyproxy.io/docs/envoy/latest/configuration/overview/examples#dynamic
grpcOptions := append(make([]grpc.ServerOption, 0, 4),
grpc.MaxConcurrentStreams(grpcMaxConcurrentStreams),
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: grpcKeepaliveTime,
Timeout: grpcKeepaliveTimeout,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: grpcKeepaliveMinTime,
PermitWithoutStream: true,
}),
)
grpcServer := grpc.NewServer(grpcOptions...)
logger.Info("Opening listener", "addr", p.ListenAddr)
lis, err := net.Listen("tcp", p.ListenAddr)
if err != nil {
return fmt.Errorf("opening listener: %w", err)
}
logger.Info("Preparing health endpoints")
healthService := grpchealth.NewServer()
healthService.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
reflection.Register(grpcServer)
discoverygrpc.RegisterAggregatedDiscoveryServiceServer(grpcServer, srv)
endpointservice.RegisterEndpointDiscoveryServiceServer(grpcServer, srv)
clusterservice.RegisterClusterDiscoveryServiceServer(grpcServer, srv)
routeservice.RegisterRouteDiscoveryServiceServer(grpcServer, srv)
listenerservice.RegisterListenerDiscoveryServiceServer(grpcServer, srv)
secretservice.RegisterSecretDiscoveryServiceServer(grpcServer, srv)
runtimeservice.RegisterRuntimeDiscoveryServiceServer(grpcServer, srv)
grpc_health_v1.RegisterHealthServer(grpcServer, healthService)
// discoverygrpc.AggregatedDiscoveryService_ServiceDesc.ServiceName
endpointsController := controlplane.EndpointsController{
Client: watcherClient,
Cache: cache,
}
errOut := make(chan error)
go func(errOut chan<- error) {
logger.Info("Starting gRPC server")
errOut <- grpcServer.Serve(lis)
}(errOut)
go func(errOut chan<- error) {
logger.Info("Staring endpoints controller")
errOut <- endpointsController.Run(ctx)
}(errOut)
go func(errOut chan error) {
for out := range errOut {
err = errors.Join(err, out)
}
}(errOut)
<-ctx.Done()
grpcServer.Stop()
return err
}
func (p controlPlane) AfterApply(kongctx *kong.Context) error {
kongctx.BindTo(cache.NewSnapshotCache(false, cache.IDHash{}, nil), (*cache.SnapshotCache)(nil))
return nil
}

View file

@ -17,26 +17,22 @@ limitations under the License.
package main package main
import ( import (
"crypto/tls" "context"
"flag"
"os" "os"
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them. // to ensure that exec-entrypoint and run can make use of them.
"github.com/alecthomas/kong"
"go.uber.org/zap/zapcore"
_ "k8s.io/client-go/plugin/pkg/client/auth" _ "k8s.io/client-go/plugin/pkg/client/auth"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme" clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime" ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1" supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/controller"
// +kubebuilder:scaffold:imports // +kubebuilder:scaffold:imports
) )
@ -52,117 +48,45 @@ func init() {
// +kubebuilder:scaffold:scheme // +kubebuilder:scaffold:scheme
} }
func main() { type app struct {
var metricsAddr string Manager manager `cmd:"" name:"manager" help:"Run the Kubernetes operator"`
var enableLeaderElection bool ControlPlane controlPlane `cmd:"" name:"control-plane" help:"Run the Envoy control plane"`
var probeAddr string
var secureMetrics bool Logging struct {
var enableHTTP2 bool Development bool `name:"development" default:"false"`
var tlsOpts []func(*tls.Config) Level zapcore.Level `name:"level" default:"info"`
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ StacktraceLevel zapcore.Level `name:"stacktrace-level" default:"warn"`
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") } `embed:"" prefix:"logging."`
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") }
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"Enable leader election for controller manager. "+ func (a app) AfterApply(kongctx *kong.Context) error {
"Enabling this will ensure there is only one active controller manager.")
flag.BoolVar(&secureMetrics, "metrics-secure", true,
"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
flag.BoolVar(&enableHTTP2, "enable-http2", false,
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
opts := zap.Options{ opts := zap.Options{
Development: true, Development: a.Logging.Development,
} Level: a.Logging.Level,
opts.BindFlags(flag.CommandLine) StacktraceLevel: a.Logging.StacktraceLevel,
flag.Parse() TimeEncoder: zapcore.ISO8601TimeEncoder,
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
// if the enable-http2 flag is false (the default), http/2 should be disabled
// due to its vulnerabilities. More specifically, disabling http/2 will
// prevent from being vulnerable to the HTTP/2 Stream Cancellation and
// Rapid Reset CVEs. For more information see:
// - https://github.com/advisories/GHSA-qppj-fm5r-hxr3
// - https://github.com/advisories/GHSA-4374-p667-p6c8
disableHTTP2 := func(c *tls.Config) {
setupLog.Info("disabling http/2")
c.NextProtos = []string{"http/1.1"}
} }
if !enableHTTP2 { logger := zap.New(zap.UseFlagOptions(&opts))
tlsOpts = append(tlsOpts, disableHTTP2) ctrl.SetLogger(logger)
} kongctx.Bind(logger)
webhookServer := webhook.NewServer(webhook.Options{ logger.Info("Completed logger setup")
TLSOpts: tlsOpts,
})
// Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. return nil
// More info: }
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server
// - https://book.kubebuilder.io/reference/metrics.html
metricsServerOptions := metricsserver.Options{
BindAddress: metricsAddr,
SecureServing: secureMetrics,
TLSOpts: tlsOpts,
}
if secureMetrics { func main() {
// FilterProvider is used to protect the metrics endpoint with authn/authz. var app app
// These configurations ensure that only authorized users and service accounts
// can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info:
// https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/filters#WithAuthenticationAndAuthorization
metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization
// TODO(user): If CertDir, CertName, and KeyName are not specified, controller-runtime will automatically kongCtx := kong.Parse(
// generate self-signed certificates for the metrics server. While convenient for development and testing, &app,
// this setup is not recommended for production. kong.Name("supabase-operator"),
} kong.BindTo(ctrl.SetupSignalHandler(), (*context.Context)(nil)),
)
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ if err := kongCtx.Run(); err != nil {
Scheme: scheme, setupLog.Error(err, "failed to run app")
Metrics: metricsServerOptions,
WebhookServer: webhookServer,
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "05f9463f.k8s.icb4dc0.de",
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
// when the Manager ends. This requires the binary to immediately end when the
// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
// speeds up voluntary leader transitions as the new leader don't have to wait
// LeaseDuration time first.
//
// In the default scaffold provided, the program ends immediately after
// the manager stops, so would be fine to enable this option. However,
// if you are doing or is intended to do any operation such as perform cleanups
// after the manager stops then its usage might be unsafe.
// LeaderElectionReleaseOnCancel: true,
})
if err != nil {
setupLog.Error(err, "unable to start manager")
os.Exit(1)
}
if err = (&controller.CoreReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Core")
os.Exit(1)
}
// +kubebuilder:scaffold:builder
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up health check")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up ready check")
os.Exit(1)
}
setupLog.Info("starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
setupLog.Error(err, "problem running manager")
os.Exit(1) os.Exit(1)
} }
} }

181
cmd/manager.go Normal file
View file

@ -0,0 +1,181 @@
/*
Copyright 2024 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 (
"context"
"crypto/tls"
"fmt"
"os"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"code.icb4dc0.de/prskr/supabase-operator/internal/controller"
webhooksupabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/internal/webhook/v1alpha1"
)
//nolint:lll // flag declaration needs to be in tags
type manager struct {
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"`
Namespace string `name:"controller-namespace" env:"CONTROLLER_NAMESPACE" default:"" help:"Namespace where the controller is running, ideally set via downward API"`
}
func (m manager) Run(ctx context.Context) error {
var tlsOpts []func(*tls.Config)
// if the enable-http2 flag is false (the default), http/2 should be disabled
// due to its vulnerabilities. More specifically, disabling http/2 will
// prevent from being vulnerable to the HTTP/2 Stream Cancellation and
// Rapid Reset CVEs. For more information see:
// - https://github.com/advisories/GHSA-qppj-fm5r-hxr3
// - https://github.com/advisories/GHSA-4374-p667-p6c8
disableHTTP2 := func(c *tls.Config) {
setupLog.Info("disabling http/2")
c.NextProtos = []string{"http/1.1"}
}
if !m.EnableHTTP2 {
tlsOpts = append(tlsOpts, disableHTTP2)
}
webhookConfig := webhooksupabasev1alpha1.WebhookConfig{
CurrentNamespace: m.Namespace,
}
webhookServer := webhook.NewServer(webhook.Options{
TLSOpts: tlsOpts,
})
// 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
// - https://book.kubebuilder.io/reference/metrics.html
metricsServerOptions := metricsserver.Options{
BindAddress: m.MetricsAddr,
SecureServing: m.SecureMetrics,
TLSOpts: tlsOpts,
}
if m.SecureMetrics {
// FilterProvider is used to protect the metrics endpoint with authn/authz.
// These configurations ensure that only authorized users and service accounts
// can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info:
// https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/filters#WithAuthenticationAndAuthorization
metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
WebhookServer: webhookServer,
HealthProbeBindAddress: m.ProbeAddr,
LeaderElection: m.EnableLeaderElection,
LeaderElectionID: "05f9463f.k8s.icb4dc0.de",
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
// when the Manager ends. This requires the binary to immediately end when the
// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
// speeds up voluntary leader transitions as the new leader don't have to wait
// LeaseDuration time first.
//
// In the default scaffold provided, the program ends immediately after
// the manager stops, so would be fine to enable this option. However,
// if you are doing or is intended to do any operation such as perform cleanups
// after the manager stops then its usage might be unsafe.
// LeaderElectionReleaseOnCancel: true,
})
if err != nil {
return fmt.Errorf("unable to start manager: %w", err)
}
if err = (&controller.CoreDbReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller Core DB: %w", err)
}
if err = (&controller.CoreJwtReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller Core JWT: %w", err)
}
if err = (&controller.CorePostgrestReconiler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller Core Postgrest: %w", err)
}
if err = (&controller.CoreAuthReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller Core Auth: %w", err)
}
if err = (&controller.DashboardPGMetaReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller Dashboard PG-Meta: %w", err)
}
// nolint:goconst
if os.Getenv("ENABLE_WEBHOOKS") != "false" {
if err = webhooksupabasev1alpha1.SetupCoreWebhookWithManager(mgr); err != nil {
return fmt.Errorf("unable to create webhook: %w", err)
}
}
if err = (&controller.APIGatewayReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(ctx, mgr); err != nil {
return fmt.Errorf("unable to create controller APIGateway: %w", err)
}
// nolint:goconst
if os.Getenv("ENABLE_WEBHOOKS") != "false" {
if err = webhooksupabasev1alpha1.SetupAPIGatewayWebhookWithManager(mgr, webhookConfig); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "APIGateway")
os.Exit(1)
}
}
// +kubebuilder:scaffold:builder
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); 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)
}
return nil
}

View file

@ -0,0 +1,35 @@
# The following manifests contain a self-signed issuer CR and a certificate CR.
# More document can be found at https://docs.cert-manager.io
# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes.
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: selfsigned-issuer
namespace: supabase-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: serving-cert # this name should match the one appeared in kustomizeconfig.yaml
namespace: supabase-system
spec:
# SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize
dnsNames:
- SERVICE_NAME.SERVICE_NAMESPACE.svc
- SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local
issuerRef:
kind: Issuer
name: selfsigned-issuer
secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize

View file

@ -0,0 +1,5 @@
resources:
- certificate.yaml
configurations:
- kustomizeconfig.yaml

View file

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

View file

@ -0,0 +1,84 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: control-plane
namespace: supabase-system
labels:
app.kubernetes.io/name: control-plane
app.kubernetes.io/managed-by: kustomize
spec:
selector:
matchLabels:
app.kubernetes.io/name: control-plane
replicas: 1
template:
metadata:
annotations:
kubectl.kubernetes.io/default-container: control-plane
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
containers:
- args:
- control-plane
image: supabase-operator:latest
name: control-plane
ports:
- containerPort: 18000
name: grpc
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
livenessProbe:
grpc:
port: 18000
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
grpc:
port: 18000
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: 500m
memory: 128Mi
requests:
cpu: 10m
memory: 64Mi
serviceAccountName: control-plane
terminationGracePeriodSeconds: 10

View file

@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- control-plane.yaml
- service.yaml

View file

@ -0,0 +1,17 @@
---
apiVersion: v1
kind: Service
metadata:
name: control-plane
namespace: supabase-system
labels:
app.kubernetes.io/name: control-plane
app.kubernetes.io/managed-by: kustomize
spec:
ports:
- name: grpc
port: 18000
protocol: TCP
targetPort: 18000
selector:
app.kubernetes.io/name: control-plane

View file

@ -0,0 +1,790 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.16.5
name: apigateways.supabase.k8s.icb4dc0.de
spec:
group: supabase.k8s.icb4dc0.de
names:
kind: APIGateway
listKind: APIGatewayList
plural: apigateways
singular: apigateway
scope: Namespaced
versions:
- name: v1alpha1
schema:
openAPIV3Schema:
description: APIGateway is the Schema for the apigateways API.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: APIGatewaySpec defines the desired state of APIGateway.
properties:
envoy:
description: Envoy - configure the envoy instance and most importantly
the control-plane
properties:
controlPlane:
description: ControlPlane - configure the control plane where
Envoy will retrieve its configuration from
properties:
host:
description: Host is the hostname of the envoy control plane
endpoint
type: string
port:
default: 18000
description: Port is the port number of the envoy control
plane endpoint - typically this is 18000
maximum: 65535
type: integer
required:
- host
- port
type: object
workloadTemplate:
description: WorkloadTemplate - customize the Envoy deployment
properties:
additionalLabels:
additionalProperties:
type: string
type: object
replicas:
format: int32
type: integer
securityContext:
description: |-
PodSecurityContext holds pod-level security attributes and common container settings.
Some fields are also present in container.securityContext. Field values of
container.securityContext take precedence over field values of PodSecurityContext.
properties:
appArmorProfile:
description: |-
appArmorProfile is the AppArmor options to use by the containers in this pod.
Note that this field cannot be set when spec.os.name is windows.
properties:
localhostProfile:
description: |-
localhostProfile indicates a profile loaded on the node that should be used.
The profile must be preconfigured on the node to work.
Must match the loaded name of the profile.
Must be set if and only if type is "Localhost".
type: string
type:
description: |-
type indicates which kind of AppArmor profile will be applied.
Valid options are:
Localhost - a profile pre-loaded on the node.
RuntimeDefault - the container runtime's default profile.
Unconfined - no AppArmor enforcement.
type: string
required:
- type
type: object
fsGroup:
description: |-
A special supplemental group that applies to all containers in a pod.
Some volume types allow the Kubelet to change the ownership of that volume
to be owned by the pod:
1. The owning GID will be the FSGroup
2. The setgid bit is set (new files created in the volume will be owned by FSGroup)
3. The permission bits are OR'd with rw-rw----
If unset, the Kubelet will not modify the ownership and permissions of any volume.
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
fsGroupChangePolicy:
description: |-
fsGroupChangePolicy defines behavior of changing ownership and permission of the volume
before being exposed inside Pod. This field will only apply to
volume types which support fsGroup based ownership(and permissions).
It will have no effect on ephemeral volume types such as: secret, configmaps
and emptydir.
Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used.
Note that this field cannot be set when spec.os.name is windows.
type: string
runAsGroup:
description: |-
The GID to run the entrypoint of the container process.
Uses runtime default if unset.
May also be set in SecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence
for that container.
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
runAsNonRoot:
description: |-
Indicates that the container must run as a non-root user.
If true, the Kubelet will validate the image at runtime to ensure that it
does not run as UID 0 (root) and fail to start the container if it does.
If unset or false, no such validation will be performed.
May also be set in SecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence.
type: boolean
runAsUser:
description: |-
The UID to run the entrypoint of the container process.
Defaults to user specified in image metadata if unspecified.
May also be set in SecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence
for that container.
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
seLinuxOptions:
description: |-
The SELinux context to be applied to all containers.
If unspecified, the container runtime will allocate a random SELinux context for each
container. May also be set in SecurityContext. If set in
both SecurityContext and PodSecurityContext, the value specified in SecurityContext
takes precedence for that container.
Note that this field cannot be set when spec.os.name is windows.
properties:
level:
description: Level is SELinux level label that applies
to the container.
type: string
role:
description: Role is a SELinux role label that applies
to the container.
type: string
type:
description: Type is a SELinux type label that applies
to the container.
type: string
user:
description: User is a SELinux user label that applies
to the container.
type: string
type: object
seccompProfile:
description: |-
The seccomp options to use by the containers in this pod.
Note that this field cannot be set when spec.os.name is windows.
properties:
localhostProfile:
description: |-
localhostProfile indicates a profile defined in a file on the node should be used.
The profile must be preconfigured on the node to work.
Must be a descending path, relative to the kubelet's configured seccomp profile location.
Must be set if type is "Localhost". Must NOT be set for any other type.
type: string
type:
description: |-
type indicates which kind of seccomp profile will be applied.
Valid options are:
Localhost - a profile defined in a file on the node should be used.
RuntimeDefault - the container runtime default profile should be used.
Unconfined - no profile should be applied.
type: string
required:
- type
type: object
supplementalGroups:
description: |-
A list of groups applied to the first process run in each container, in
addition to the container's primary GID and fsGroup (if specified). If
the SupplementalGroupsPolicy feature is enabled, the
supplementalGroupsPolicy field determines whether these are in addition
to or instead of any group memberships defined in the container image.
If unspecified, no additional groups are added, though group memberships
defined in the container image may still be used, depending on the
supplementalGroupsPolicy field.
Note that this field cannot be set when spec.os.name is windows.
items:
format: int64
type: integer
type: array
x-kubernetes-list-type: atomic
supplementalGroupsPolicy:
description: |-
Defines how supplemental groups of the first container processes are calculated.
Valid values are "Merge" and "Strict". If not specified, "Merge" is used.
(Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled
and the container runtime must implement support for this feature.
Note that this field cannot be set when spec.os.name is windows.
type: string
sysctls:
description: |-
Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported
sysctls (by the container runtime) might fail to launch.
Note that this field cannot be set when spec.os.name is windows.
items:
description: Sysctl defines a kernel parameter to be
set
properties:
name:
description: Name of a property to set
type: string
value:
description: Value of a property to set
type: string
required:
- name
- value
type: object
type: array
x-kubernetes-list-type: atomic
windowsOptions:
description: |-
The Windows specific settings applied to all containers.
If unspecified, the options within a container's SecurityContext will be used.
If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence.
Note that this field cannot be set when spec.os.name is linux.
properties:
gmsaCredentialSpec:
description: |-
GMSACredentialSpec is where the GMSA admission webhook
(https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the
GMSA credential spec named by the GMSACredentialSpecName field.
type: string
gmsaCredentialSpecName:
description: GMSACredentialSpecName is the name of
the GMSA credential spec to use.
type: string
hostProcess:
description: |-
HostProcess determines if a container should be run as a 'Host Process' container.
All of a Pod's containers must have the same effective HostProcess value
(it is not allowed to have a mix of HostProcess containers and non-HostProcess containers).
In addition, if HostProcess is true then HostNetwork must also be set to true.
type: boolean
runAsUserName:
description: |-
The UserName in Windows to run the entrypoint of the container process.
Defaults to the user specified in image metadata if unspecified.
May also be set in PodSecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence.
type: string
type: object
type: object
workload:
description: Workload - customize the container template of
the workload
properties:
additionalEnv:
items:
description: EnvVar represents an environment variable
present in a Container.
properties:
name:
description: Name of the environment variable. Must
be a C_IDENTIFIER.
type: string
value:
description: |-
Variable references $(VAR_NAME) are expanded
using the previously defined environment variables in the container and
any service environment variables. If a variable cannot be resolved,
the reference in the input string will be unchanged. Double $$ are reduced
to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e.
"$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)".
Escaped references will never be expanded, regardless of whether the variable
exists or not.
Defaults to "".
type: string
valueFrom:
description: Source for the environment variable's
value. Cannot be used if value is not empty.
properties:
configMapKeyRef:
description: Selects a key of a ConfigMap.
properties:
key:
description: The key to select.
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 ConfigMap
or its key must be defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
fieldRef:
description: |-
Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['<KEY>']`, `metadata.annotations['<KEY>']`,
spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs.
properties:
apiVersion:
description: Version of the schema the FieldPath
is written in terms of, defaults to "v1".
type: string
fieldPath:
description: Path of the field to select
in the specified API version.
type: string
required:
- fieldPath
type: object
x-kubernetes-map-type: atomic
resourceFieldRef:
description: |-
Selects a resource of the container: only resources limits and requests
(limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported.
properties:
containerName:
description: 'Container name: required for
volumes, optional for env vars'
type: string
divisor:
anyOf:
- type: integer
- type: string
description: Specifies the output format
of the exposed resources, defaults to
"1"
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
resource:
description: 'Required: resource to select'
type: string
required:
- resource
type: object
x-kubernetes-map-type: atomic
secretKeyRef:
description: Selects a key of a secret in the
pod's namespace
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
type: object
required:
- name
type: object
type: array
image:
type: string
imagePullSecrets:
items:
description: |-
LocalObjectReference contains enough information to let you locate the
referenced object inside the same namespace.
properties:
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
type: object
x-kubernetes-map-type: atomic
type: array
pullPolicy:
description: PullPolicy describes a policy for if/when
to pull a container image
type: string
resources:
description: ResourceRequirements describes the compute
resource requirements.
properties:
claims:
description: |-
Claims lists the names of resources, defined in spec.resourceClaims,
that are used by this container.
This is an alpha field and requires enabling the
DynamicResourceAllocation feature gate.
This field is immutable. It can only be set for containers.
items:
description: ResourceClaim references one entry
in PodSpec.ResourceClaims.
properties:
name:
description: |-
Name must match the name of one entry in pod.spec.resourceClaims of
the Pod where this field is used. It makes that resource available
inside a container.
type: string
request:
description: |-
Request is the name chosen for a request in the referenced claim.
If empty, everything from the claim is made available, otherwise
only the result of this request.
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
limits:
additionalProperties:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
description: |-
Limits describes the maximum amount of compute resources allowed.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
type: object
requests:
additionalProperties:
anyOf:
- type: integer
- type: string
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
description: |-
Requests describes the minimum amount of compute resources required.
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
otherwise to an implementation-defined value. Requests cannot exceed Limits.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
type: object
type: object
securityContext:
description: SecurityContext -
properties:
allowPrivilegeEscalation:
description: |-
AllowPrivilegeEscalation controls whether a process can gain more
privileges than its parent process. This bool directly controls if
the no_new_privs flag will be set on the container process.
AllowPrivilegeEscalation is true always when the container is:
1) run as Privileged
2) has CAP_SYS_ADMIN
Note that this field cannot be set when spec.os.name is windows.
type: boolean
appArmorProfile:
description: |-
appArmorProfile is the AppArmor options to use by this container. If set, this profile
overrides the pod's appArmorProfile.
Note that this field cannot be set when spec.os.name is windows.
properties:
localhostProfile:
description: |-
localhostProfile indicates a profile loaded on the node that should be used.
The profile must be preconfigured on the node to work.
Must match the loaded name of the profile.
Must be set if and only if type is "Localhost".
type: string
type:
description: |-
type indicates which kind of AppArmor profile will be applied.
Valid options are:
Localhost - a profile pre-loaded on the node.
RuntimeDefault - the container runtime's default profile.
Unconfined - no AppArmor enforcement.
type: string
required:
- type
type: object
capabilities:
description: |-
The capabilities to add/drop when running containers.
Defaults to the default set of capabilities granted by the container runtime.
Note that this field cannot be set when spec.os.name is windows.
properties:
add:
description: Added capabilities
items:
description: Capability represent POSIX capabilities
type
type: string
type: array
x-kubernetes-list-type: atomic
drop:
description: Removed capabilities
items:
description: Capability represent POSIX capabilities
type
type: string
type: array
x-kubernetes-list-type: atomic
type: object
privileged:
description: |-
Run container in privileged mode.
Processes in privileged containers are essentially equivalent to root on the host.
Defaults to false.
Note that this field cannot be set when spec.os.name is windows.
type: boolean
procMount:
description: |-
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
readonly paths and masked paths.
This requires the ProcMountType feature flag to be enabled.
Note that this field cannot be set when spec.os.name is windows.
type: string
readOnlyRootFilesystem:
description: |-
Whether this container has a read-only root filesystem.
Default is false.
Note that this field cannot be set when spec.os.name is windows.
type: boolean
runAsGroup:
description: |-
The GID to run the entrypoint of the container process.
Uses runtime default if unset.
May also be set in PodSecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence.
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
runAsNonRoot:
description: |-
Indicates that the container must run as a non-root user.
If true, the Kubelet will validate the image at runtime to ensure that it
does not run as UID 0 (root) and fail to start the container if it does.
If unset or false, no such validation will be performed.
May also be set in PodSecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence.
type: boolean
runAsUser:
description: |-
The UID to run the entrypoint of the container process.
Defaults to user specified in image metadata if unspecified.
May also be set in PodSecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence.
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
seLinuxOptions:
description: |-
The SELinux context to be applied to the container.
If unspecified, the container runtime will allocate a random SELinux context for each
container. May also be set in PodSecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence.
Note that this field cannot be set when spec.os.name is windows.
properties:
level:
description: Level is SELinux level label that
applies to the container.
type: string
role:
description: Role is a SELinux role label that
applies to the container.
type: string
type:
description: Type is a SELinux type label that
applies to the container.
type: string
user:
description: User is a SELinux user label that
applies to the container.
type: string
type: object
seccompProfile:
description: |-
The seccomp options to use by this container. If seccomp options are
provided at both the pod & container level, the container options
override the pod options.
Note that this field cannot be set when spec.os.name is windows.
properties:
localhostProfile:
description: |-
localhostProfile indicates a profile defined in a file on the node should be used.
The profile must be preconfigured on the node to work.
Must be a descending path, relative to the kubelet's configured seccomp profile location.
Must be set if type is "Localhost". Must NOT be set for any other type.
type: string
type:
description: |-
type indicates which kind of seccomp profile will be applied.
Valid options are:
Localhost - a profile defined in a file on the node should be used.
RuntimeDefault - the container runtime default profile should be used.
Unconfined - no profile should be applied.
type: string
required:
- type
type: object
windowsOptions:
description: |-
The Windows specific settings applied to all containers.
If unspecified, the options from the PodSecurityContext will be used.
If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence.
Note that this field cannot be set when spec.os.name is linux.
properties:
gmsaCredentialSpec:
description: |-
GMSACredentialSpec is where the GMSA admission webhook
(https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the
GMSA credential spec named by the GMSACredentialSpecName field.
type: string
gmsaCredentialSpecName:
description: GMSACredentialSpecName is the name
of the GMSA credential spec to use.
type: string
hostProcess:
description: |-
HostProcess determines if a container should be run as a 'Host Process' container.
All of a Pod's containers must have the same effective HostProcess value
(it is not allowed to have a mix of HostProcess containers and non-HostProcess containers).
In addition, if HostProcess is true then HostNetwork must also be set to true.
type: boolean
runAsUserName:
description: |-
The UserName in Windows to run the entrypoint of the container process.
Defaults to the user specified in image metadata if unspecified.
May also be set in PodSecurityContext. If set in both SecurityContext and
PodSecurityContext, the value specified in SecurityContext takes precedence.
type: string
type: object
type: object
volumeMounts:
items:
description: VolumeMount describes a mounting of a Volume
within a container.
properties:
mountPath:
description: |-
Path within the container at which the volume should be mounted. Must
not contain ':'.
type: string
mountPropagation:
description: |-
mountPropagation determines how mounts are propagated from the host
to container and the other way around.
When not set, MountPropagationNone is used.
This field is beta in 1.10.
When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified
(which defaults to None).
type: string
name:
description: This must match the Name of a Volume.
type: string
readOnly:
description: |-
Mounted read-only if true, read-write otherwise (false or unspecified).
Defaults to false.
type: boolean
recursiveReadOnly:
description: |-
RecursiveReadOnly specifies whether read-only mounts should be handled
recursively.
If ReadOnly is false, this field has no meaning and must be unspecified.
If ReadOnly is true, and this field is set to Disabled, the mount is not made
recursively read-only. If this field is set to IfPossible, the mount is made
recursively read-only, if it is supported by the container runtime. If this
field is set to Enabled, the mount is made recursively read-only if it is
supported by the container runtime, otherwise the pod will not be started and
an error will be generated to indicate the reason.
If this field is set to IfPossible or Enabled, MountPropagation must be set to
None (or be unspecified, which defaults to None).
If this field is not specified, it is treated as an equivalent of Disabled.
type: string
subPath:
description: |-
Path within the volume from which the container's volume should be mounted.
Defaults to "" (volume's root).
type: string
subPathExpr:
description: |-
Expanded path within the volume from which the container's volume should be mounted.
Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment.
Defaults to "" (volume's root).
SubPathExpr and SubPath are mutually exclusive.
type: string
required:
- mountPath
- name
type: object
type: array
type: object
required:
- securityContext
type: object
required:
- controlPlane
type: object
jwks:
description: JWKSSelector - selector where the JWKS can be retrieved
from to enable the API gateway to validate JWTs
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
required:
- envoy
- jwks
type: object
status:
description: APIGatewayStatus defines the observed state of APIGateway.
type: object
type: object
served: true
storage: true
subresources:
status: {}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,8 +2,10 @@
# since it depends on service name and namespace that are out of this kustomize package. # since it depends on service name and namespace that are out of this kustomize package.
# It should be run by config/default # It should be run by config/default
resources: resources:
- bases/supabase.k8s.icb4dc0.de_cores.yaml - bases/supabase.k8s.icb4dc0.de_cores.yaml
# +kubebuilder:scaffold:crdkustomizeresource - bases/supabase.k8s.icb4dc0.de_apigateways.yaml
- bases/supabase.k8s.icb4dc0.de_dashboards.yaml
# +kubebuilder:scaffold:crdkustomizeresource
patches: patches:
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix.
@ -16,5 +18,5 @@ patches:
# [WEBHOOK] To enable webhook, uncomment the following section # [WEBHOOK] To enable webhook, uncomment the following section
# the following config is for teaching kustomize how to do kustomization for CRDs. # the following config is for teaching kustomize how to do kustomization for CRDs.
#configurations: configurations:
#- kustomizeconfig.yaml - kustomizeconfig.yaml

View file

@ -1,12 +1,12 @@
# Adds namespace to all resources. # Adds namespace to all resources.
namespace: supabase-operator-system namespace: supabase-system
# Value of this field is prepended to the # Value of this field is prepended to the
# names of all resources, e.g. a deployment named # names of all resources, e.g. a deployment named
# "wordpress" becomes "alices-wordpress". # "wordpress" becomes "alices-wordpress".
# Note that it should also match with the prefix (text before '-') of the namespace # Note that it should also match with the prefix (text before '-') of the namespace
# field above. # field above.
namePrefix: supabase-operator- namePrefix: supabase-
# Labels to add to all resources and selectors. # Labels to add to all resources and selectors.
#labels: #labels:
@ -15,18 +15,16 @@ namePrefix: supabase-operator-
# someName: someValue # someName: someValue
resources: resources:
- ../crd - ../crd
- ../rbac - ../rbac
- ../manager - ../manager
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in - ../control-plane
# crd/kustomization.yaml - ../webhook
#- ../webhook - ../certmanager
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
#- ../certmanager #- ../prometheus
# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. # [METRICS] Expose the controller manager metrics service.
#- ../prometheus - metrics_service.yaml
# [METRICS] Expose the controller manager metrics service.
- metrics_service.yaml
# [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. # [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. # Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics.
# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will # Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will
@ -35,116 +33,116 @@ resources:
# Uncomment the patches line if you enable Metrics, and/or are using webhooks and cert-manager # Uncomment the patches line if you enable Metrics, and/or are using webhooks and cert-manager
patches: patches:
# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. # [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443.
# More info: https://book.kubebuilder.io/reference/metrics # More info: https://book.kubebuilder.io/reference/metrics
- path: manager_metrics_patch.yaml - path: manager_metrics_patch.yaml
target: target:
kind: Deployment kind: Deployment
name: controller-manager
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
# crd/kustomization.yaml # crd/kustomization.yaml
#- path: manager_webhook_patch.yaml - path: manager_webhook_patch.yaml
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
# Uncomment the following replacements to add the cert-manager CA injection annotations # Uncomment the following replacements to add the cert-manager CA injection annotations
#replacements: replacements:
# - source: # Uncomment the following block if you have any webhook - source: # Uncomment the following block if you have any webhook
# kind: Service kind: Service
# version: v1 version: v1
# name: webhook-service name: webhook-service
# fieldPath: .metadata.name # Name of the service fieldPath: .metadata.name # Name of the service
# targets: targets:
# - select: - select:
# kind: Certificate kind: Certificate
# group: cert-manager.io group: cert-manager.io
# version: v1 version: v1
# fieldPaths: fieldPaths:
# - .spec.dnsNames.0 - .spec.dnsNames.0
# - .spec.dnsNames.1 - .spec.dnsNames.1
# options: options:
# delimiter: '.' delimiter: "."
# index: 0 index: 0
# create: true create: true
# - source: - source:
# kind: Service kind: Service
# version: v1 version: v1
# name: webhook-service name: webhook-service
# fieldPath: .metadata.namespace # Namespace of the service fieldPath: .metadata.namespace # Namespace of the service
# targets: targets:
# - select: - select:
# kind: Certificate kind: Certificate
# group: cert-manager.io group: cert-manager.io
# version: v1 version: v1
# fieldPaths: fieldPaths:
# - .spec.dnsNames.0 - .spec.dnsNames.0
# - .spec.dnsNames.1 - .spec.dnsNames.1
# options: options:
# delimiter: '.' delimiter: "."
# index: 1 index: 1
# create: true create: true
#
# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation)
# kind: Certificate kind: Certificate
# group: cert-manager.io group: cert-manager.io
# version: v1 version: v1
# name: serving-cert # This name should match the one in certificate.yaml name: serving-cert # This name should match the one in certificate.yaml
# fieldPath: .metadata.namespace # Namespace of the certificate CR fieldPath: .metadata.namespace # Namespace of the certificate CR
# targets: targets:
# - select: - select:
# kind: ValidatingWebhookConfiguration kind: ValidatingWebhookConfiguration
# fieldPaths: fieldPaths:
# - .metadata.annotations.[cert-manager.io/inject-ca-from] - .metadata.annotations.[cert-manager.io/inject-ca-from]
# options: options:
# delimiter: '/' delimiter: "/"
# index: 0 index: 0
# create: true create: true
# - source: - source:
# kind: Certificate kind: Certificate
# group: cert-manager.io group: cert-manager.io
# version: v1 version: v1
# name: serving-cert # This name should match the one in certificate.yaml name: serving-cert # This name should match the one in certificate.yaml
# fieldPath: .metadata.name fieldPath: .metadata.name
# targets: targets:
# - select: - select:
# kind: ValidatingWebhookConfiguration kind: ValidatingWebhookConfiguration
# fieldPaths: fieldPaths:
# - .metadata.annotations.[cert-manager.io/inject-ca-from] - .metadata.annotations.[cert-manager.io/inject-ca-from]
# options: options:
# delimiter: '/' delimiter: "/"
# index: 1 index: 1
# create: true create: true
#
# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting )
# kind: Certificate kind: Certificate
# group: cert-manager.io group: cert-manager.io
# version: v1 version: v1
# name: serving-cert # This name should match the one in certificate.yaml name: serving-cert # This name should match the one in certificate.yaml
# fieldPath: .metadata.namespace # Namespace of the certificate CR fieldPath: .metadata.namespace # Namespace of the certificate CR
# targets: targets:
# - select: - select:
# kind: MutatingWebhookConfiguration kind: MutatingWebhookConfiguration
# fieldPaths: fieldPaths:
# - .metadata.annotations.[cert-manager.io/inject-ca-from] - .metadata.annotations.[cert-manager.io/inject-ca-from]
# options: options:
# delimiter: '/' delimiter: "/"
# index: 0 index: 0
# create: true create: true
# - source: - source:
# kind: Certificate kind: Certificate
# group: cert-manager.io group: cert-manager.io
# version: v1 version: v1
# name: serving-cert # This name should match the one in certificate.yaml name: serving-cert # This name should match the one in certificate.yaml
# fieldPath: .metadata.name fieldPath: .metadata.name
# targets: targets:
# - select: - select:
# kind: MutatingWebhookConfiguration kind: MutatingWebhookConfiguration
# fieldPaths: fieldPaths:
# - .metadata.annotations.[cert-manager.io/inject-ca-from] - .metadata.annotations.[cert-manager.io/inject-ca-from]
# options: options:
# delimiter: '/' delimiter: "/"
# index: 1 index: 1
# create: true create: true
#
# - source: # Uncomment the following block if you have a ConversionWebhook (--conversion) # - source: # Uncomment the following block if you have a ConversionWebhook (--conversion)
# kind: Certificate # kind: Certificate
# group: cert-manager.io # group: cert-manager.io

View file

@ -1,4 +1,4 @@
# This patch adds the args to allow exposing the metrics endpoint using HTTPS # This patch adds the args to allow exposing the metrics endpoint using HTTPS
- op: add - op: add
path: /spec/template/spec/containers/0/args/0 path: /spec/template/spec/containers/0/args/1
value: --metrics-bind-address=:8443 value: --metrics-bind-address=:8443

View file

@ -0,0 +1,26 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller-manager
namespace: supabase-system
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
spec:
template:
spec:
containers:
- name: manager
ports:
- containerPort: 9443
name: webhook-server
protocol: TCP
volumeMounts:
- mountPath: /tmp/k8s-webhook-server/serving-certs
name: cert
readOnly: true
volumes:
- name: cert
secret:
defaultMode: 420
secretName: webhook-server-cert

View file

@ -6,12 +6,12 @@ metadata:
app.kubernetes.io/name: supabase-operator app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize app.kubernetes.io/managed-by: kustomize
name: controller-manager-metrics-service name: controller-manager-metrics-service
namespace: system namespace: supabase-system
spec: spec:
ports: ports:
- name: https - name: https
port: 8443 port: 8443
protocol: TCP protocol: TCP
targetPort: 8443 targetPort: 8443
selector: selector:
control-plane: controller-manager control-plane: controller-manager

View file

@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../default
patches:
- path: manager_dev_settings.yaml
target:
kind: Deployment

View file

@ -0,0 +1,12 @@
- op: add
path: /spec/template/spec/containers/0/args/1
value: --logging.development=true
- op: replace
path: /spec/template/spec/containers/0/resources
value:
limits:
cpu: 500m
memory: 768Mi
requests:
cpu: 250m
memory: 384Mi

View file

@ -5,13 +5,13 @@ metadata:
control-plane: controller-manager control-plane: controller-manager
app.kubernetes.io/name: supabase-operator app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize app.kubernetes.io/managed-by: kustomize
name: system name: supabase-system
--- ---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: controller-manager name: controller-manager
namespace: system namespace: supabase-system
labels: labels:
control-plane: controller-manager control-plane: controller-manager
app.kubernetes.io/name: supabase-operator app.kubernetes.io/name: supabase-operator
@ -50,46 +50,45 @@ spec:
# - linux # - linux
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
# TODO(user): For common cases that do not require escalating privileges seccompProfile:
# it is recommended to ensure that all your Pods/Containers are restrictive. type: RuntimeDefault
# 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
containers: containers:
- command: - args:
- /manager - manager
args: - --leader-elect
- --leader-elect - --health-probe-bind-address=:8081
- --health-probe-bind-address=:8081 image: supabase-operator:latest
image: controller:latest name: manager
name: manager env:
securityContext: - name: CONTROLLER_NAMESPACE
allowPrivilegeEscalation: false valueFrom:
capabilities: fieldRef:
drop: fieldPath: metadata.namespace
- "ALL" securityContext:
livenessProbe: allowPrivilegeEscalation: false
httpGet: capabilities:
path: /healthz drop:
port: 8081 - "ALL"
initialDelaySeconds: 15 livenessProbe:
periodSeconds: 20 httpGet:
readinessProbe: path: /healthz
httpGet: port: 8081
path: /readyz initialDelaySeconds: 15
port: 8081 periodSeconds: 20
initialDelaySeconds: 5 readinessProbe:
periodSeconds: 10 httpGet:
# TODO(user): Configure the resources accordingly based on the project requirements. path: /readyz
# More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ port: 8081
resources: initialDelaySeconds: 5
limits: periodSeconds: 10
cpu: 500m # TODO(user): Configure the resources accordingly based on the project requirements.
memory: 128Mi # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
requests: resources:
cpu: 10m limits:
memory: 64Mi cpu: 500m
memory: 128Mi
requests:
cpu: 10m
memory: 64Mi
serviceAccountName: controller-manager serviceAccountName: controller-manager
terminationGracePeriodSeconds: 10 terminationGracePeriodSeconds: 10

View file

@ -8,7 +8,7 @@ metadata:
app.kubernetes.io/name: supabase-operator app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize app.kubernetes.io/managed-by: kustomize
name: allow-metrics-traffic name: allow-metrics-traffic
namespace: system namespace: supabase-system
spec: spec:
podSelector: podSelector:
matchLabels: matchLabels:
@ -18,9 +18,9 @@ spec:
ingress: ingress:
# This allows ingress traffic from any namespace with the label metrics: enabled # This allows ingress traffic from any namespace with the label metrics: enabled
- from: - from:
- namespaceSelector: - namespaceSelector:
matchLabels: matchLabels:
metrics: enabled # Only from namespaces with this label metrics: enabled # Only from namespaces with this label
ports: ports:
- port: 8443 - port: 8443
protocol: TCP protocol: TCP

View file

@ -0,0 +1,26 @@
# This NetworkPolicy allows ingress traffic to your webhook server running
# as part of the controller-manager from specific namespaces and pods. CR(s) which uses webhooks
# will only work when applied in namespaces labeled with 'webhook: enabled'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: allow-webhook-traffic
namespace: supabase-system
spec:
podSelector:
matchLabels:
control-plane: controller-manager
policyTypes:
- Ingress
ingress:
# This allows ingress traffic from any namespace with the label webhook: enabled
- from:
- namespaceSelector:
matchLabels:
webhook: enabled # Only from namespaces with this label
ports:
- port: 443
protocol: TCP

View file

@ -1,2 +1,3 @@
resources: resources:
- allow-webhook-traffic.yaml
- allow-metrics-traffic.yaml - allow-metrics-traffic.yaml

View file

@ -7,7 +7,7 @@ metadata:
app.kubernetes.io/name: supabase-operator app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize app.kubernetes.io/managed-by: kustomize
name: controller-manager-metrics-monitor name: controller-manager-metrics-monitor
namespace: system namespace: supabase-system
spec: spec:
endpoints: endpoints:
- path: /metrics - path: /metrics

View file

@ -0,0 +1,27 @@
# permissions for end users to edit apigateways.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: apigateway-editor-role
rules:
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
- apigateways
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
- apigateways/status
verbs:
- get

View file

@ -0,0 +1,23 @@
# permissions for end users to view apigateways.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: apigateway-viewer-role
rules:
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
- apigateways
verbs:
- get
- list
- watch
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
- apigateways/status
verbs:
- get

View file

@ -0,0 +1,14 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: control-plane-role
rules:
- apiGroups:
- discovery.k8s.io
resources:
- endpointslices
verbs:
- get
- list
- watch

View file

@ -0,0 +1,15 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: control-plane-rolebinding
labels:
app.kubernetes.io/name: control-plane
app.kubernetes.io/managed-by: kustomize
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: control-plane-role
subjects:
- kind: ServiceAccount
name: control-plane
namespace: supabase-system

View file

@ -0,0 +1,8 @@
apiVersion: v1
kind: ServiceAccount
metadata:
labels:
app.kubernetes.io/name: control-plane
app.kubernetes.io/managed-by: kustomize
name: control-plane
namespace: supabase-system

View file

@ -0,0 +1,27 @@
# permissions for end users to edit dashboards.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: dashboard-editor-role
rules:
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
- dashboards
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
- dashboards/status
verbs:
- get

View file

@ -0,0 +1,23 @@
# permissions for end users to view dashboards.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: dashboard-viewer-role
rules:
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
- dashboards
verbs:
- get
- list
- watch
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
- dashboards/status
verbs:
- get

View file

@ -1,27 +1,38 @@
resources: resources:
# All RBAC will be applied under this service account in # All RBAC will be applied under this service account in
# the deployment namespace. You may comment out this resource # the deployment namespace. You may comment out this resource
# if your manager will use a service account that exists at # if your manager will use a service account that exists at
# runtime. Be sure to update RoleBinding and ClusterRoleBinding # runtime. Be sure to update RoleBinding and ClusterRoleBinding
# subjects if changing service account names. # subjects if changing service account names.
- service_account.yaml - service_account.yaml
- role.yaml - role.yaml
- role_binding.yaml - role_binding.yaml
- leader_election_role.yaml - leader_election_role.yaml
- leader_election_role_binding.yaml - leader_election_role_binding.yaml
# The following RBAC configurations are used to protect # RBAC role for the control plane
# the metrics endpoint with authn/authz. These configurations - control-plane-service_account.yaml
# ensure that only authorized users and service accounts - control-plane-role.yaml
# can access the metrics endpoint. Comment the following - control-plane-role_binding.yaml
# permissions if you want to disable this protection. # The following RBAC configurations are used to protect
# More info: https://book.kubebuilder.io/reference/metrics.html # the metrics endpoint with authn/authz. These configurations
- metrics_auth_role.yaml # ensure that only authorized users and service accounts
- metrics_auth_role_binding.yaml # can access the metrics endpoint. Comment the following
- metrics_reader_role.yaml # permissions if you want to disable this protection.
# For each CRD, "Editor" and "Viewer" roles are scaffolded by # More info: https://book.kubebuilder.io/reference/metrics.html
# default, aiding admins in cluster management. Those roles are - metrics_auth_role.yaml
# not used by the Project itself. You can comment the following lines - metrics_auth_role_binding.yaml
# if you do not want those helpers be installed with your Project. - metrics_reader_role.yaml
- core_editor_role.yaml # For each CRD, "Editor" and "Viewer" roles are scaffolded by
- core_viewer_role.yaml # default, aiding admins in cluster management. Those roles are
# not used by the Project itself. You can comment the following lines
# if you do not want those helpers be installed with your Project.
- apigateway_editor_role.yaml
- apigateway_viewer_role.yaml
- core_editor_role.yaml
- core_viewer_role.yaml
# For each CRD, "Editor" and "Viewer" roles are scaffolded by
# default, aiding admins in cluster management. Those roles are
# not used by the Project itself. You can comment the following lines
# if you do not want those helpers be installed with your Project.
- dashboard_editor_role.yaml
- dashboard_viewer_role.yaml

View file

@ -10,6 +10,6 @@ roleRef:
kind: Role kind: Role
name: leader-election-role name: leader-election-role
subjects: subjects:
- kind: ServiceAccount - kind: ServiceAccount
name: controller-manager name: controller-manager
namespace: system namespace: supabase-system

View file

@ -7,6 +7,6 @@ roleRef:
kind: ClusterRole kind: ClusterRole
name: metrics-auth-role name: metrics-auth-role
subjects: subjects:
- kind: ServiceAccount - kind: ServiceAccount
name: controller-manager name: controller-manager
namespace: system namespace: supabase-system

View file

@ -5,9 +5,29 @@ metadata:
name: manager-role name: manager-role
rules: rules:
- apiGroups: - apiGroups:
- supabase.k8s.icb4dc0.de - ""
resources: resources:
- cores - configmaps
- secrets
- services
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- ""
resources:
- events
verbs:
- create
- apiGroups:
- apps
resources:
- deployments
verbs: verbs:
- create - create
- delete - delete
@ -19,13 +39,31 @@ rules:
- apiGroups: - apiGroups:
- supabase.k8s.icb4dc0.de - supabase.k8s.icb4dc0.de
resources: resources:
- apigateways
- cores
- dashboards
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
- apigateways/finalizers
- cores/finalizers - cores/finalizers
- dashboards/finalizers
verbs: verbs:
- update - update
- apiGroups: - apiGroups:
- supabase.k8s.icb4dc0.de - supabase.k8s.icb4dc0.de
resources: resources:
- apigateways/status
- cores/status - cores/status
- dashboards/status
verbs: verbs:
- get - get
- patch - patch

View file

@ -10,6 +10,6 @@ roleRef:
kind: ClusterRole kind: ClusterRole
name: manager-role name: manager-role
subjects: subjects:
- kind: ServiceAccount - kind: ServiceAccount
name: controller-manager name: controller-manager
namespace: system namespace: supabase-system

View file

@ -5,4 +5,4 @@ metadata:
app.kubernetes.io/name: supabase-operator app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize app.kubernetes.io/managed-by: kustomize
name: controller-manager name: controller-manager
namespace: system namespace: supabase-system

View file

@ -40,7 +40,7 @@ metadata:
name: cluster-example name: cluster-example
spec: spec:
instances: 1 instances: 1
imageName: ghcr.io/supabase/postgres:15.6.1.145 imageName: ghcr.io/supabase/postgres:15.8.1.021
postgresUID: 105 postgresUID: 105
postgresGID: 106 postgresGID: 106

View file

@ -1,4 +1,11 @@
## Append samples of your project ## ## Append samples of your project ##
namespace: supabase-demo
resources: resources:
- supabase_v1alpha1_core.yaml - namespace.yaml
# +kubebuilder:scaffold:manifestskustomizesamples - cnpg-cluster.yaml
- supabase_v1alpha1_core.yaml
- supabase_v1alpha1_apigateway.yaml
- supabase_v1alpha1_dashboard.yaml
# +kubebuilder:scaffold:manifestskustomizesamples

View file

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

View file

@ -0,0 +1,12 @@
apiVersion: supabase.k8s.icb4dc0.de/v1alpha1
kind: APIGateway
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: core-sample
spec:
envoy:
controlPlane:
host: supabase-control-plane.supabase-system.svc
port: 18000

View file

@ -1,3 +1,10 @@
apiVersion: v1
kind: Secret
metadata:
name: supabase-demo-credentials
stringData:
url: postgresql://supabase_admin:1n1t-R00t!@cluster-example-rw.supabase-demo:5432/app
---
apiVersion: supabase.k8s.icb4dc0.de/v1alpha1 apiVersion: supabase.k8s.icb4dc0.de/v1alpha1
kind: Core kind: Core
metadata: metadata:
@ -8,5 +15,11 @@ metadata:
spec: spec:
database: database:
dsnFrom: dsnFrom:
name: example-cluster-credentials name: supabase-demo-credentials
key: url key: url
auth:
externalUrl: http://localhost:8000/
siteUrl: http://localhost:3000/
disableSignup: true
enableEmailAutoconfirm: true
providers: {}

View file

@ -0,0 +1,13 @@
apiVersion: supabase.k8s.icb4dc0.de/v1alpha1
kind: Dashboard
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: core-sample
spec:
db:
host: cluster-example-rw.supabase-demo
dbName: app
dbCredentialsRef:
name: db-roles-creds-supabase-admin

View file

@ -0,0 +1,6 @@
resources:
- manifests.yaml
- service.yaml
configurations:
- kustomizeconfig.yaml

View file

@ -0,0 +1,22 @@
# the following config is for teaching kustomize where to look at when substituting nameReference.
# It requires kustomize v2.1.0 or newer to work properly.
nameReference:
- kind: Service
version: v1
fieldSpecs:
- kind: MutatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/name
- kind: ValidatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/name
namespace:
- kind: MutatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/namespace
create: true
- kind: ValidatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/namespace
create: true

View file

@ -0,0 +1,92 @@
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: mutating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /mutate-supabase-k8s-icb4dc0-de-v1alpha1-apigateway
failurePolicy: Fail
name: mapigateway-v1alpha1.kb.io
rules:
- apiGroups:
- supabase.k8s.icb4dc0.de
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- apigateways
sideEffects: None
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /mutate-supabase-k8s-icb4dc0-de-v1alpha1-core
failurePolicy: Fail
name: mcore-v1alpha1.kb.io
rules:
- apiGroups:
- supabase.k8s.icb4dc0.de
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- cores
sideEffects: None
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: validating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /validate-supabase-k8s-icb4dc0-de-v1alpha1-apigateway
failurePolicy: Fail
name: vapigateway-v1alpha1.kb.io
rules:
- apiGroups:
- supabase.k8s.icb4dc0.de
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- apigateways
sideEffects: None
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /validate-supabase-k8s-icb4dc0-de-v1alpha1-core
failurePolicy: Fail
name: vcore-v1alpha1.kb.io
rules:
- apiGroups:
- supabase.k8s.icb4dc0.de
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- cores
sideEffects: None

View file

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: webhook-service
namespace: supabase-system
spec:
ports:
- port: 443
protocol: TCP
targetPort: 9443
selector:
control-plane: controller-manager

9
crd-docs.yaml Normal file
View file

@ -0,0 +1,9 @@
processor:
ignoreTypes: []
ignoreFields:
- "status$"
- "TypeMeta$"
render:
kubernetesVersion: "1.30"
knownTypes: []

5
dev/Dockerfile Normal file
View file

@ -0,0 +1,5 @@
FROM alpine:3.21
WORKDIR /app
ADD --chown=65532:65532 ./out ./bin
USER 65532:65532
ENTRYPOINT [ "/app/bin/supabase-operator" ]

9
dev/cluster.yaml Normal file
View file

@ -0,0 +1,9 @@
apiVersion: ctlptl.dev/v1alpha1
kind: Registry
name: ctlptl-registry
port: 5005
---
apiVersion: ctlptl.dev/v1alpha1
kind: Cluster
product: kind
registry: ctlptl-registry

4
dev/prepare-dev-cluster.sh Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.24/releases/cnpg-1.24.1.yaml

View file

@ -0,0 +1,560 @@
# API Reference
## Packages
- [supabase.k8s.icb4dc0.de/v1alpha1](#supabasek8sicb4dc0dev1alpha1)
## supabase.k8s.icb4dc0.de/v1alpha1
Package v1alpha1 contains API Schema definitions for the supabase v1alpha1 API group.
### Resource Types
- [APIGateway](#apigateway)
- [APIGatewayList](#apigatewaylist)
- [Core](#core)
- [CoreList](#corelist)
#### APIGateway
APIGateway is the Schema for the apigateways API.
_Appears in:_
- [APIGatewayList](#apigatewaylist)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `apiVersion` _string_ | `supabase.k8s.icb4dc0.de/v1alpha1` | | |
| `kind` _string_ | `APIGateway` | | |
| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
| `spec` _[APIGatewaySpec](#apigatewayspec)_ | | | |
#### APIGatewayList
APIGatewayList contains a list of APIGateway.
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `apiVersion` _string_ | `supabase.k8s.icb4dc0.de/v1alpha1` | | |
| `kind` _string_ | `APIGatewayList` | | |
| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
| `items` _[APIGateway](#apigateway) array_ | | | |
#### APIGatewaySpec
APIGatewaySpec defines the desired state of APIGateway.
_Appears in:_
- [APIGateway](#apigateway)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `envoy` _[EnvoySpec](#envoyspec)_ | Envoy - configure the envoy instance and most importantly the control-plane | | |
| `jwks` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretkeyselector-v1-core)_ | JWKSSelector - selector where the JWKS can be retrieved from to enable the API gateway to validate JWTs | | |
#### AuthProviderMeta
_Appears in:_
- [AzureAuthProvider](#azureauthprovider)
- [EmailAuthProvider](#emailauthprovider)
- [GithubAuthProvider](#githubauthprovider)
- [PhoneAuthProvider](#phoneauthprovider)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `enabled` _boolean_ | Enabled - whether the authentication provider is enabled or not | | |
#### AuthProviders
_Appears in:_
- [AuthSpec](#authspec)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `email` _[EmailAuthProvider](#emailauthprovider)_ | | | |
| `azure` _[AzureAuthProvider](#azureauthprovider)_ | | | |
| `github` _[GithubAuthProvider](#githubauthprovider)_ | | | |
| `phone` _[PhoneAuthProvider](#phoneauthprovider)_ | | | |
#### AuthSpec
_Appears in:_
- [CoreSpec](#corespec)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `externalUrl` _string_ | APIExternalURL is referring to the URL where Supabase API will be available<br />Typically this is the ingress of the API gateway | | |
| `siteUrl` _string_ | SiteURL is referring to the URL of the (frontend) application<br />In most Kubernetes scenarios this is the same as the APIExternalURL with a different path handler in the ingress | | |
| `additionalRedirectUrls` _string array_ | | | |
| `disableSignup` _boolean_ | | | |
| `anonymousUsersEnabled` _boolean_ | | | |
| `providers` _[AuthProviders](#authproviders)_ | | | |
| `workloadTemplate` _[WorkloadTemplate](#workloadtemplate)_ | | | |
| `emailSignupDisabled` _boolean_ | | | |
#### AzureAuthProvider
_Appears in:_
- [AuthProviders](#authproviders)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `enabled` _boolean_ | Enabled - whether the authentication provider is enabled or not | | |
| `clientID` _string_ | | | |
| `clientSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretkeyselector-v1-core)_ | | | |
| `url` _string_ | | | |
#### ContainerTemplate
_Appears in:_
- [WorkloadTemplate](#workloadtemplate)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `image` _string_ | | | |
| `pullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#pullpolicy-v1-core)_ | | | |
| `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core) array_ | | | |
| `securityContext` _[SecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#securitycontext-v1-core)_ | | | |
| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#resourcerequirements-v1-core)_ | | | |
| `volumeMounts` _[VolumeMount](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#volumemount-v1-core) array_ | | | |
| `additionalEnv` _[EnvVar](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#envvar-v1-core) array_ | | | |
#### ControlPlaneSpec
_Appears in:_
- [EnvoySpec](#envoyspec)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `host` _string_ | Host is the hostname of the envoy control plane endpoint | | |
| `port` _integer_ | Port is the port number of the envoy control plane endpoint - typically this is 18000 | 18000 | Maximum: 65535 <br /> |
#### Core
Core is the Schema for the cores API.
_Appears in:_
- [CoreList](#corelist)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `apiVersion` _string_ | `supabase.k8s.icb4dc0.de/v1alpha1` | | |
| `kind` _string_ | `Core` | | |
| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
| `spec` _[CoreSpec](#corespec)_ | | | |
#### CoreCondition
_Appears in:_
- [CoreStatus](#corestatus)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `type` _[CoreConditionType](#coreconditiontype)_ | | | |
| `lastProbeTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#time-v1-meta)_ | | | |
| `lastTransitionTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#time-v1-meta)_ | | | |
| `reason` _string_ | | | |
| `message` _string_ | | | |
#### CoreConditionType
_Underlying type:_ _string_
_Appears in:_
- [CoreCondition](#corecondition)
#### CoreList
CoreList contains a list of Core.
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `apiVersion` _string_ | `supabase.k8s.icb4dc0.de/v1alpha1` | | |
| `kind` _string_ | `CoreList` | | |
| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
| `items` _[Core](#core) array_ | | | |
#### CoreSpec
CoreSpec defines the desired state of Core.
_Appears in:_
- [Core](#core)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `jwt` _[JwtSpec](#jwtspec)_ | | | |
| `database` _[Database](#database)_ | | | |
| `postgrest` _[PostgrestSpec](#postgrestspec)_ | | | |
| `auth` _[AuthSpec](#authspec)_ | | | |
#### Database
_Appears in:_
- [CoreSpec](#corespec)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `dsn` _string_ | | | |
| `dsnFrom` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretkeyselector-v1-core)_ | | | |
| `roles` _[DatabaseRoles](#databaseroles)_ | | | |
#### DatabaseRoles
_Appears in:_
- [Database](#database)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `selfManaged` _boolean_ | SelfManaged - whether the database roles are managed externally<br />when enabled the operator does not attempt to create secrets, generate passwords or whatsoever for all database roles<br />i.e. all secrets need to be provided or the instance won't work | | |
| `secrets` _[DatabaseRolesSecrets](#databaserolessecrets)_ | Secrets - typed 'map' of secrets for each database role that Supabase needs | | |
#### DatabaseRolesSecrets
_Appears in:_
- [DatabaseRoles](#databaseroles)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `authenticator` _[SecretReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretreference-v1-core)_ | | | |
| `supabaseAuthAdmin` _[SecretReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretreference-v1-core)_ | | | |
| `supabaseFunctionsAdmin` _[SecretReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretreference-v1-core)_ | | | |
| `supabaseStorageAdmin` _[SecretReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretreference-v1-core)_ | | | |
#### DatabaseStatus
_Appears in:_
- [CoreStatus](#corestatus)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `appliedMigrations` _[MigrationStatus](#migrationstatus)_ | | | |
| `roles` _object (keys:string, values:integer array)_ | | | |
#### EmailAuthProvider
_Appears in:_
- [AuthProviders](#authproviders)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `enabled` _boolean_ | Enabled - whether the authentication provider is enabled or not | | |
| `adminEmail` _string_ | | | |
| `senderName` _string_ | | | |
| `autoconfirmEmail` _boolean_ | | | |
| `subjectsInvite` _string_ | | | |
| `subjectsConfirmation` _string_ | | | |
| `smtpSpec` _[EmailAuthSmtpSpec](#emailauthsmtpspec)_ | | | |
#### EmailAuthSmtpSpec
_Appears in:_
- [EmailAuthProvider](#emailauthprovider)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `host` _string_ | | | |
| `port` _integer_ | | | |
| `maxFrequency` _integer_ | | | |
| `credentialsFrom` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | | |
#### EnvoySpec
_Appears in:_
- [APIGatewaySpec](#apigatewayspec)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `controlPlane` _[ControlPlaneSpec](#controlplanespec)_ | ControlPlane - configure the control plane where Envoy will retrieve its configuration from | | |
| `workloadTemplate` _[WorkloadTemplate](#workloadtemplate)_ | WorkloadTemplate - customize the Envoy deployment | | |
#### GithubAuthProvider
_Appears in:_
- [AuthProviders](#authproviders)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `enabled` _boolean_ | Enabled - whether the authentication provider is enabled or not | | |
| `clientID` _string_ | | | |
| `clientSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretkeyselector-v1-core)_ | | | |
| `url` _string_ | | | |
#### ImageSpec
_Appears in:_
- [ContainerTemplate](#containertemplate)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `image` _string_ | | | |
| `pullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#pullpolicy-v1-core)_ | | | |
#### JwtSpec
_Appears in:_
- [CoreSpec](#corespec)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `secret` _string_ | Secret - JWT HMAC secret in plain text<br />This is WRITE-ONLY and will be copied to the SecretRef by the defaulter | | |
| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | SecretRef - object reference to the Secret where JWT values are stored | | |
| `secretKey` _string_ | SecretKey - key in secret where to read the JWT HMAC secret from | secret | |
| `jwksKey` _string_ | JwksKey - key in secret where to read the JWKS from | jwks.json | |
| `anonKey` _string_ | AnonKey - key in secret where to read the anon JWT from | anon_key | |
| `serviceKey` _string_ | ServiceKey - key in secret where to read the service JWT from | service_key | |
| `expiry` _integer_ | Expiry - expiration time in seconds for JWTs | 3600 | |
#### MigrationStatus
_Underlying type:_ _object_
_Appears in:_
- [DatabaseStatus](#databasestatus)
#### OAuthProvider
_Appears in:_
- [AzureAuthProvider](#azureauthprovider)
- [GithubAuthProvider](#githubauthprovider)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `clientID` _string_ | | | |
| `clientSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#secretkeyselector-v1-core)_ | | | |
| `url` _string_ | | | |
#### PhoneAuthProvider
_Appears in:_
- [AuthProviders](#authproviders)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `enabled` _boolean_ | Enabled - whether the authentication provider is enabled or not | | |
#### PostgrestSpec
_Appears in:_
- [CoreSpec](#corespec)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `schemas` _string array_ | Schemas - schema where PostgREST is looking for objects (tables, views, functions, ...) | [public graphql_public] | UniqueItems: true <br /> |
| `extraSearchPath` _string array_ | ExtraSearchPath - Extra schemas to add to the search_path of every request.<br />These schemas tables, views and functions dont get API endpoints, they can only be referred from the database objects inside your db-schemas. | [public extensions] | UniqueItems: true <br /> |
| `anonRole` _string_ | AnonRole - name of the anon role | anon | |
| `maxRows` _integer_ | MaxRows - maximum number of rows PostgREST will load at a time | 1000 | |
| `workloadTemplate` _[WorkloadTemplate](#workloadtemplate)_ | WorkloadTemplate - customize the PostgREST workload | | |
#### WorkloadTemplate
_Appears in:_
- [AuthSpec](#authspec)
- [EnvoySpec](#envoyspec)
- [PostgrestSpec](#postgrestspec)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `replicas` _integer_ | | | |
| `securityContext` _[PodSecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#podsecuritycontext-v1-core)_ | | | |
| `additionalLabels` _object (keys:string, values:string)_ | | | |
| `workload` _[ContainerTemplate](#containertemplate)_ | | | |

0
docs/getting_started.md Normal file
View file

17
docs/index.md Normal file
View file

@ -0,0 +1,17 @@
# Welcome to MkDocs
For full documentation visit [mkdocs.org](https://www.mkdocs.org).
## Commands
* `mkdocs new [dir-name]` - Create a new project.
* `mkdocs serve` - Start the live-reloading docs server.
* `mkdocs build` - Build the documentation site.
* `mkdocs -h` - Print help message and exit.
## Project layout
mkdocs.yml # The configuration file.
docs/
index.md # The documentation homepage.
... # Other markdown pages, images and other files.

18
docs/overrides/main.html Normal file
View file

@ -0,0 +1,18 @@
{% extends "base.html" %} {% block extrahead %}
<!-- Add scripts that need to run before here -->
{{ super() }}
<!-- Add scripts that need to run afterwards here -->
<script
defer
data-domain="docs.supabase-operator.icb4dc0.de"
src="https://plausible.icb4dc0.de/js/script.js"
></script>
<script>
window.plausible =
window.plausible ||
function () {
(window.plausible.q = window.plausible.q || []).push(arguments);
};
</script>
{% endblock %}

34
go.mod
View file

@ -3,11 +3,16 @@ module code.icb4dc0.de/prskr/supabase-operator
go 1.23.4 go 1.23.4
require ( require (
github.com/alecthomas/kong v1.6.0
github.com/envoyproxy/go-control-plane v0.13.1
github.com/jackc/pgx/v5 v5.7.1 github.com/jackc/pgx/v5 v5.7.1
github.com/lestrrat-go/jwx/v2 v2.1.3
github.com/magefile/mage v1.15.0 github.com/magefile/mage v1.15.0
github.com/onsi/ginkgo/v2 v2.19.0 github.com/onsi/ginkgo/v2 v2.19.0
github.com/onsi/gomega v1.33.1 github.com/onsi/gomega v1.33.1
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc go.uber.org/zap v1.26.0
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.34.2
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.31.0 k8s.io/api v0.31.0
k8s.io/apimachinery v0.31.0 k8s.io/apimachinery v0.31.0
@ -16,14 +21,19 @@ require (
) )
require ( require (
cel.dev/expr v0.15.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect 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/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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.11.0 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
@ -35,6 +45,7 @@ require (
github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.4 // indirect github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
@ -51,15 +62,22 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_golang v1.19.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect
@ -73,21 +91,19 @@ require (
go.opentelemetry.io/otel/trace v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect golang.org/x/crypto v0.29.0 // indirect
golang.org/x/crypto v0.27.0 // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
golang.org/x/net v0.26.0 // indirect golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.24.0 // indirect golang.org/x/term v0.26.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/text v0.20.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/apiextensions-apiserver v0.31.0 // indirect k8s.io/apiextensions-apiserver v0.31.0 // indirect

59
go.sum
View file

@ -1,3 +1,11 @@
cel.dev/expr v0.15.0 h1:O1jzfJCQBfL5BFoYktaxwIhuttaQPsVWerH9/EEKx0w=
cel.dev/expr v0.15.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg=
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=
github.com/alecthomas/kong v1.6.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA=
@ -8,16 +16,26 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g=
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.13.1 h1:vPfJZCkob6yTMEgS+0TwfTUfbHjfy/6vOJ8hUWX/uXE=
github.com/envoyproxy/go-control-plane v0.13.1/go.mod h1:X45hY0mufo6Fd0KW3rqsGvQMw58jvjymeCzBU3mWyHw=
github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM=
github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
@ -44,6 +62,8 @@ github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogB
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@ -66,6 +86,8 @@ 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= 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/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=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@ -91,6 +113,18 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo=
github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
@ -108,6 +142,8 @@ github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -122,6 +158,8 @@ github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoG
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@ -133,6 +171,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@ -168,8 +207,8 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@ -185,19 +224,19 @@ golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View file

@ -0,0 +1,458 @@
/*
Copyright 2024 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 controller
import (
"bytes"
"context"
"embed"
"encoding/hex"
"errors"
"fmt"
"text/template"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
var (
templates *template.Template
//go:embed templates/*.tmpl
templateFS embed.FS
ErrNoJwksConfigured = errors.New("no JWKS configured")
)
const (
jwksSecretNameField = ".spec.jwks.name"
)
func init() {
templates = template.Must(template.ParseFS(templateFS, "templates/*.tmpl"))
}
// APIGatewayReconciler reconciles a APIGateway object
type APIGatewayReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/reconcile
func (r *APIGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) {
var (
gateway supabasev1alpha1.APIGateway
logger = log.FromContext(ctx)
envoyConfigHash, jwksHash string
)
logger.Info("Reconciling APIGateway")
if err := r.Get(ctx, req.NamespacedName, &gateway); client.IgnoreNotFound(err) != nil {
logger.Error(err, "unable to fetch Gateway")
return ctrl.Result{}, err
}
if jwksHash, err = r.reconcileJwksSecret(ctx, &gateway); err != nil {
return ctrl.Result{}, err
}
if envoyConfigHash, err = r.reconcileEnvoyConfig(ctx, &gateway); err != nil {
return ctrl.Result{}, err
}
if err := r.reconileEnvoyDeployment(ctx, &gateway, envoyConfigHash, jwksHash); err != nil {
if client.IgnoreNotFound(err) == nil {
logger.Error(err, "expected resource does not exist (yet), waiting for it to be present")
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
return ctrl.Result{}, err
}
if err := r.reconcileEnvoyService(ctx, &gateway); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *APIGatewayReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error {
err := mgr.GetFieldIndexer().IndexField(ctx, new(supabasev1alpha1.APIGateway), jwksSecretNameField, func(o client.Object) []string {
gw, ok := o.(*supabasev1alpha1.APIGateway)
if !ok {
return nil
}
return []string{gw.Spec.JWKSSelector.Name}
})
if err != nil {
return fmt.Errorf("setting up field index for JWKS secret name: %w", err)
}
reloadSelector, err := predicate.LabelSelectorPredicate(metav1.LabelSelector{
MatchLabels: map[string]string{
meta.SupabaseLabel.Reload: "",
},
})
if err != nil {
return fmt.Errorf("constructor selector for watching secrets: %w", err)
}
return ctrl.NewControllerManagedBy(mgr).
For(&supabasev1alpha1.APIGateway{}).
Named("apigateway").
Owns(new(corev1.ConfigMap)).
Owns(new(appsv1.Deployment)).
Owns(new(corev1.Service)).
Watches(
new(corev1.Secret),
FieldSelectorEventHandler[*supabasev1alpha1.APIGateway, *supabasev1alpha1.APIGatewayList](r.Client,
jwksSecretNameField,
),
builder.WithPredicates(
predicate.ResourceVersionChangedPredicate{},
reloadSelector,
),
).
Complete(r)
}
func (r *APIGatewayReconciler) reconcileJwksSecret(
ctx context.Context,
gateway *supabasev1alpha1.APIGateway,
) (jwksHash string, err error) {
jwksSecret := &corev1.Secret{ObjectMeta: gateway.JwksSecretMeta()}
if err := r.Get(ctx, client.ObjectKeyFromObject(jwksSecret), jwksSecret); err != nil {
return "", err
}
jwksRaw, ok := jwksSecret.Data[gateway.Spec.JWKSSelector.Key]
if !ok {
return "", fmt.Errorf("%w in secret %s", ErrNoJwksConfigured, jwksSecret.Name)
}
return hex.EncodeToString(HashBytes(jwksRaw)), nil
}
func (r *APIGatewayReconciler) reconcileEnvoyConfig(
ctx context.Context,
gateway *supabasev1alpha1.APIGateway,
) (configHash string, err error) {
configMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: supabase.ServiceConfig.Envoy.ObjectName(gateway),
Namespace: gateway.Namespace,
},
}
_, err = controllerutil.CreateOrUpdate(ctx, r.Client, configMap, func() error {
configMap.Labels = MergeLabels(objectLabels(gateway, "envoy", "api-gateway", supabase.Images.Postgrest.Tag), gateway.Labels)
type nodeSpec struct {
Cluster string
ID string
}
type controlPlaneSpec struct {
Name string
Host string
Port uint16
}
instance := fmt.Sprintf("%s:%s", gateway.Name, gateway.Namespace)
tmplData := struct {
Node nodeSpec
ControlPlane controlPlaneSpec
}{
Node: nodeSpec{
ID: instance,
Cluster: instance,
},
ControlPlane: controlPlaneSpec{
Name: "supabase-control-plane",
Host: gateway.Spec.Envoy.ControlPlane.Host,
Port: gateway.Spec.Envoy.ControlPlane.Port,
},
}
bytesBuf := bytes.NewBuffer(nil)
if err := templates.ExecuteTemplate(bytesBuf, "envoy_control_plane_config.yaml.tmpl", tmplData); err != nil {
return err
}
configMap.Data = map[string]string{
"config.yaml": bytesBuf.String(),
}
if err := controllerutil.SetControllerReference(gateway, configMap, r.Scheme); err != nil {
return err
}
return nil
})
if err != nil {
return "", err
}
configHash = hex.EncodeToString(HashStrings(configMap.Data[supabase.ServiceConfig.Envoy.Defaults.ConfigKey]))
return configHash, nil
}
func (r *APIGatewayReconciler) reconileEnvoyDeployment(
ctx context.Context,
gateway *supabasev1alpha1.APIGateway,
configHash, jwksHash string,
) error {
envoyDeployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: supabase.ServiceConfig.Envoy.ObjectName(gateway),
Namespace: gateway.Namespace,
},
}
envoySpec := gateway.Spec.Envoy
if envoySpec == nil {
envoySpec = new(supabasev1alpha1.EnvoySpec)
}
if envoySpec.WorkloadTemplate == nil {
envoySpec.WorkloadTemplate = new(supabasev1alpha1.WorkloadTemplate)
}
if envoySpec.WorkloadTemplate.Workload == nil {
envoySpec.WorkloadTemplate.Workload = new(supabasev1alpha1.ContainerTemplate)
}
var (
image = supabase.Images.Envoy.String()
podSecurityContext = envoySpec.WorkloadTemplate.SecurityContext
pullPolicy = envoySpec.WorkloadTemplate.Workload.PullPolicy
containerSecurityContext = envoySpec.WorkloadTemplate.Workload.SecurityContext
)
if img := envoySpec.WorkloadTemplate.Workload.Image; img != "" {
image = img
}
if podSecurityContext == nil {
podSecurityContext = &corev1.PodSecurityContext{
RunAsNonRoot: ptrOf(true),
}
}
if containerSecurityContext == nil {
containerSecurityContext = &corev1.SecurityContext{
Privileged: ptrOf(false),
RunAsUser: ptrOf(int64(65532)),
RunAsGroup: ptrOf(int64(65532)),
RunAsNonRoot: ptrOf(true),
AllowPrivilegeEscalation: ptrOf(false),
ReadOnlyRootFilesystem: ptrOf(true),
Capabilities: &corev1.Capabilities{
Drop: []corev1.Capability{
"ALL",
},
},
}
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, envoyDeployment, func() error {
envoyDeployment.Labels = MergeLabels(
objectLabels(gateway, "envoy", "api-gateway", supabase.Images.Postgrest.Tag),
gateway.Labels,
envoySpec.WorkloadTemplate.AdditionalLabels,
)
if envoyDeployment.CreationTimestamp.IsZero() {
envoyDeployment.Spec.Selector = &metav1.LabelSelector{
MatchLabels: selectorLabels(gateway, "envoy"),
}
}
envoyDeployment.Spec.Replicas = envoySpec.WorkloadTemplate.Replicas
envoyDeployment.Spec.Template = corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
fmt.Sprintf("%s/%s", supabasev1alpha1.GroupVersion.Group, "config-hash"): configHash,
fmt.Sprintf("%s/%s", supabasev1alpha1.GroupVersion.Group, "jwks-hash"): jwksHash,
},
Labels: MergeLabels(
objectLabels(gateway, "envoy", "api-gateway", supabase.Images.Envoy.Tag),
envoySpec.WorkloadTemplate.AdditionalLabels,
),
},
Spec: corev1.PodSpec{
ImagePullSecrets: envoySpec.WorkloadTemplate.Workload.ImagePullSecrets,
AutomountServiceAccountToken: ptrOf(false),
Containers: []corev1.Container{
{
Name: "envoy-proxy",
Image: image,
ImagePullPolicy: pullPolicy,
Args: []string{"-c /etc/envoy/config.yaml"},
Ports: []corev1.ContainerPort{
{
Name: "http",
ContainerPort: 8000,
Protocol: corev1.ProtocolTCP,
},
{
Name: "admin",
ContainerPort: 19000,
Protocol: corev1.ProtocolTCP,
},
},
ReadinessProbe: &corev1.Probe{
InitialDelaySeconds: 5,
PeriodSeconds: 3,
TimeoutSeconds: 1,
SuccessThreshold: 2,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/ready",
Port: intstr.IntOrString{IntVal: 19000},
},
},
},
LivenessProbe: &corev1.Probe{
InitialDelaySeconds: 10,
PeriodSeconds: 5,
TimeoutSeconds: 3,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/ready",
Port: intstr.IntOrString{IntVal: 19000},
},
},
},
SecurityContext: containerSecurityContext,
Resources: envoySpec.WorkloadTemplate.Workload.Resources,
VolumeMounts: []corev1.VolumeMount{
{
Name: "config",
ReadOnly: true,
MountPath: "/etc/envoy",
},
},
},
},
SecurityContext: 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.JWKSSelector.Name,
},
Items: []corev1.KeyToPath{{
Key: gateway.Spec.JWKSSelector.Key,
Path: "jwks.json",
}},
},
},
},
},
},
},
},
},
}
if err := controllerutil.SetControllerReference(gateway, envoyDeployment, r.Scheme); err != nil {
return err
}
return nil
})
return err
}
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,
},
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, envoyService, func() error {
envoyService.Labels = MergeLabels(objectLabels(gateway, "envoy", "api-gateway", supabase.Images.Postgrest.Tag), gateway.Labels)
envoyService.Spec = corev1.ServiceSpec{
Selector: selectorLabels(gateway, "postgrest"),
Ports: []corev1.ServicePort{
{
Name: "rest",
Protocol: corev1.ProtocolTCP,
AppProtocol: ptrOf("http"),
Port: 8000,
TargetPort: intstr.IntOrString{IntVal: 8000},
},
},
}
if err := controllerutil.SetControllerReference(gateway, envoyService, r.Scheme); err != nil {
return err
}
return nil
})
return err
}

View file

@ -0,0 +1,84 @@
/*
Copyright 2024 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 controller
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
)
var _ = Describe("APIGateway Controller", func() {
Context("When reconciling a resource", func() {
const resourceName = "test-resource"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default", // TODO(user):Modify as needed
}
apigateway := &supabasev1alpha1.APIGateway{}
BeforeEach(func() {
By("creating the custom resource for the Kind APIGateway")
err := k8sClient.Get(ctx, typeNamespacedName, apigateway)
if err != nil && errors.IsNotFound(err) {
resource := &supabasev1alpha1.APIGateway{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: "default",
},
// TODO(user): Specify other spec details if needed.
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
}
})
AfterEach(func() {
// TODO(user): Cleanup logic after each test, like removing the resource instance.
resource := &supabasev1alpha1.APIGateway{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
Expect(err).NotTo(HaveOccurred())
By("Cleanup the specific resource instance APIGateway")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
})
It("should successfully reconcile the resource", func() {
By("Reconciling the created resource")
controllerReconciler := &APIGatewayReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
}
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())
// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
// Example: If you expect a certain status condition after reconciliation, verify it here.
})
})
})

View file

@ -1,132 +0,0 @@
/*
Copyright 2024 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 controller
import (
"context"
"errors"
"io"
"github.com/jackc/pgx/v5"
appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"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/assets/migrations"
"code.icb4dc0.de/prskr/supabase-operator/infrastructure/db"
)
// CoreReconciler reconciles a Core object
type CoreReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=cores,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=cores/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=cores/finalizers,verbs=update
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the Core object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/reconcile
func (r *CoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) {
logger := log.FromContext(ctx)
var core supabasev1alpha1.Core
if err := r.Get(ctx, req.NamespacedName, &core); client.IgnoreNotFound(err) != nil {
logger.Error(err, "unable to fetch Core")
return ctrl.Result{}, err
}
dsn, err := core.Spec.Database.GetDSN(ctx, client.NewNamespacedClient(r.Client, req.Namespace))
if err != nil {
logger.Error(err, "unable to get DSN")
return ctrl.Result{}, err
}
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
logger.Error(err, "unable to connect to database")
return ctrl.Result{}, err
}
defer CloseCtx(ctx, conn, &err)
if err := r.applyMissingMigrations(ctx, conn, &core); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *CoreReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&supabasev1alpha1.Core{}).
Owns(new(appsv1.Deployment)).
Named("core").
Complete(r)
}
func (r *CoreReconciler) applyMissingMigrations(ctx context.Context, conn *pgx.Conn, core *supabasev1alpha1.Core) (err error) {
logger := log.FromContext(ctx)
logger.Info("Checking for outstanding migrations")
migrator := db.Migrator{Conn: conn}
var appliedSomething bool
if appliedSomething, err = migrator.ApplyAll(ctx, core.Status.AppliedMigrations, migrations.InitScripts()); err != nil {
return err
}
if appliedSomething {
logger.Info("Updating status after applying init scripts")
return r.Client.Status().Update(ctx, core)
}
if appliedSomething, err = migrator.ApplyAll(ctx, core.Status.AppliedMigrations, migrations.MigrationScripts()); err != nil {
return err
}
if appliedSomething {
logger.Info("Updating status after applying migration scripts")
return r.Client.Status().Update(ctx, core)
}
return nil
}
func Close(closer io.Closer, err *error) {
*err = errors.Join(*err, closer.Close())
}
func CloseCtx(ctx context.Context, closable interface {
Close(ctx context.Context) error
}, err *error,
) {
*err = errors.Join(*err, closable.Close(ctx))
}

View file

@ -0,0 +1,255 @@
/*
Copyright 2024 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 controller
import (
"bytes"
"context"
"crypto/sha256"
"maps"
"net/url"
"time"
"github.com/jackc/pgx/v5"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
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/log"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/assets/migrations"
"code.icb4dc0.de/prskr/supabase-operator/internal/db"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
// CoreDbReconciler reconciles a Core object
type CoreDbReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *CoreDbReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) {
logger := log.FromContext(ctx)
var core supabasev1alpha1.Core
if err := r.Get(ctx, req.NamespacedName, &core); client.IgnoreNotFound(err) != nil {
logger.Error(err, "unable to fetch Core")
return ctrl.Result{}, err
}
dsn, err := core.Spec.Database.GetDSN(ctx, client.NewNamespacedClient(r.Client, req.Namespace))
if err != nil {
logger.Error(err, "unable to get DSN")
return ctrl.Result{}, err
}
logger.Info("Connecting to database")
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
logger.Error(err, "unable to connect to database")
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}
defer CloseCtx(ctx, conn, &err)
logger.Info("Connected to database, checking for outstanding migrations")
if err := r.applyMissingMigrations(ctx, conn, &core); err != nil {
return ctrl.Result{}, err
}
logger.Info("Sync credentials for Supabase roles")
if err := r.ensureDbRolesSecrets(ctx, dsn, conn, &core); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *CoreDbReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(new(supabasev1alpha1.Core)).
Owns(new(corev1.Secret)).
Named("core-db").
Complete(r)
}
func (r *CoreDbReconciler) applyMissingMigrations(
ctx context.Context,
conn *pgx.Conn,
core *supabasev1alpha1.Core,
) (err error) {
logger := log.FromContext(ctx)
migrator := db.Migrator{Conn: conn}
var appliedSomething bool
if core.Status.Database.AppliedMigrations == nil {
core.Status.Database.AppliedMigrations = make(supabasev1alpha1.MigrationStatus)
}
if appliedSomething, err = migrator.ApplyAll(ctx, core.Status.Database.AppliedMigrations, migrations.InitScripts()); err != nil {
return err
}
if appliedSomething {
logger.Info("Updating status after applying init scripts")
return r.Client.Status().Update(ctx, core)
} else {
logger.Info("Init scripts were up to date - did not run any")
}
if appliedSomething, err = migrator.ApplyAll(ctx, core.Status.Database.AppliedMigrations, migrations.MigrationScripts()); err != nil {
return err
}
if appliedSomething {
logger.Info("Updating status after applying migration scripts")
return r.Client.Status().Update(ctx, core)
} else {
logger.Info("Migrrations were up to date - did not run any")
}
return nil
}
func (r *CoreDbReconciler) ensureDbRolesSecrets(
ctx context.Context,
dsn string,
conn *pgx.Conn,
core *supabasev1alpha1.Core,
) error {
var (
logger = log.FromContext(ctx)
rolesMgr = db.NewRolesManager(conn)
)
dbSpec := core.Spec.Database
if dbSpec.Roles.SelfManaged {
logger.Info("Database roles are self-managed, skipping reconciliation")
return nil
}
parsedDSN, err := url.Parse(dsn)
if err != nil {
return err
}
var (
dsnUser = parsedDSN.User.Username()
dsnPW, _ = parsedDSN.User.Password()
)
roles := map[string]supabase.DBRole{
dbSpec.Roles.Secrets.Authenticator.Name: supabase.DBRoleAuthenticator,
dbSpec.Roles.Secrets.AuthAdmin.Name: supabase.DBRoleAuthAdmin,
dbSpec.Roles.Secrets.FunctionsAdmin.Name: supabase.DBRoleFunctionsAdmin,
dbSpec.Roles.Secrets.StorageAdmin.Name: supabase.DBRoleStorageAdmin,
dbSpec.Roles.Secrets.Admin.Name: supabase.DBRoleSupabaseAdmin,
}
if core.Status.Database.Roles == nil {
core.Status.Database.Roles = make(map[string][]byte)
}
hash := sha256.New()
for secretName, role := range roles {
secretLogger := logger.WithValues("secret_name", secretName, "role_name", role.String())
secretLogger.Info("Ensuring credential secret")
credentialsSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: core.Namespace,
},
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, credentialsSecret, func() error {
logger.Info("Ensuring role credentials", "role_name", role.String())
credentialsSecret.Labels = maps.Clone(core.Labels)
if credentialsSecret.Labels == nil {
credentialsSecret.Labels = make(map[string]string)
}
credentialsSecret.Labels[meta.SupabaseLabel.Reload] = ""
if credentialsSecret.Data == nil {
credentialsSecret.Data = make(map[string][]byte)
}
if _, ok := credentialsSecret.Data[corev1.BasicAuthUsernameKey]; !ok {
credentialsSecret.Data[corev1.BasicAuthUsernameKey] = role.Bytes()
}
var requireStatusUpdate bool
if value := credentialsSecret.Data[corev1.BasicAuthPasswordKey]; len(value) == 0 || (role.String() == dsnUser && !bytes.Equal(credentialsSecret.Data[corev1.BasicAuthPasswordKey], []byte(dsnPW))) {
if role.String() == dsnUser {
credentialsSecret.Data[corev1.BasicAuthPasswordKey] = []byte(dsnPW)
} else {
credentialsSecret.Data[corev1.BasicAuthPasswordKey] = GeneratePW(24, nil)
}
secretLogger.Info("Update database role to match secret credentials")
if err := rolesMgr.UpdateRolePassword(ctx, role.String(), credentialsSecret.Data[corev1.BasicAuthPasswordKey]); err != nil {
return err
}
core.Status.Database.Roles[role.String()] = hash.Sum(credentialsSecret.Data[corev1.BasicAuthPasswordKey])
requireStatusUpdate = true
} else {
if bytes.Equal(core.Status.Database.Roles[role.String()], hash.Sum(credentialsSecret.Data[corev1.BasicAuthPasswordKey])) {
logger.Info("Role password is up to date", "role_name", role.String())
} else {
if err := rolesMgr.UpdateRolePassword(ctx, role.String(), credentialsSecret.Data[corev1.BasicAuthPasswordKey]); err != nil {
return err
}
requireStatusUpdate = true
}
core.Status.Database.Roles[role.String()] = hash.Sum(credentialsSecret.Data[corev1.BasicAuthPasswordKey])
}
credentialsSecret.Type = corev1.SecretTypeBasicAuth
if requireStatusUpdate {
secretLogger.Info("Updating status")
if err := r.Status().Update(ctx, core); err != nil {
return err
}
}
logger.Info("Setting owner reference for credentials secret")
if err := controllerutil.SetControllerReference(core, credentialsSecret, r.Scheme); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
}
return nil
}

View file

@ -68,7 +68,7 @@ var _ = Describe("Core Controller", func() {
}) })
It("should successfully reconcile the resource", func() { It("should successfully reconcile the resource", func() {
By("Reconciling the created resource") By("Reconciling the created resource")
controllerReconciler := &CoreReconciler{ controllerReconciler := &CoreDbReconciler{
Client: k8sClient, Client: k8sClient,
Scheme: k8sClient.Scheme(), Scheme: k8sClient.Scheme(),
} }

View file

@ -0,0 +1,288 @@
package controller
import (
"context"
"fmt"
"net/url"
"strings"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr"
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/log"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
type CoreAuthReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *CoreAuthReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) {
var (
core supabasev1alpha1.Core
logger = log.FromContext(ctx)
)
if err := r.Get(ctx, req.NamespacedName, &core); client.IgnoreNotFound(err) != nil {
logger.Error(err, "unable to fetch Core")
return ctrl.Result{}, err
}
if err := r.reconcileAuthDeployment(ctx, &core); err != nil {
if client.IgnoreNotFound(err) == nil {
logger.Error(err, "expected resource does not exist (yet), waiting for it to be present")
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
return ctrl.Result{}, err
}
if err := r.reconcileAuthService(ctx, &core); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *CoreAuthReconciler) SetupWithManager(mgr ctrl.Manager) error {
// TODO watch changes in DB credentials secret
return ctrl.NewControllerManagedBy(mgr).
For(new(supabasev1alpha1.Core)).
Owns(new(appsv1.Deployment)).
Owns(new(corev1.Service)).
Named("core-auth").
Complete(r)
}
func (r *CoreAuthReconciler) reconcileAuthDeployment(
ctx context.Context,
core *supabasev1alpha1.Core,
) error {
var (
authDeployment = &appsv1.Deployment{
ObjectMeta: supabase.ServiceConfig.Auth.ObjectMeta(core),
}
authSpec = core.Spec.Auth
svcCfg = supabase.ServiceConfig.Auth
)
if authSpec.WorkloadTemplate == nil {
authSpec.WorkloadTemplate = new(supabasev1alpha1.WorkloadTemplate)
}
if authSpec.WorkloadTemplate.Workload == nil {
authSpec.WorkloadTemplate.Workload = new(supabasev1alpha1.ContainerTemplate)
}
var (
image = supabase.Images.Gotrue.String()
podSecurityContext = authSpec.WorkloadTemplate.SecurityContext
pullPolicy = authSpec.WorkloadTemplate.Workload.PullPolicy
containerSecurityContext = authSpec.WorkloadTemplate.Workload.SecurityContext
namespacedClient = client.NewNamespacedClient(r.Client, core.Namespace)
)
if img := authSpec.WorkloadTemplate.Workload.Image; img != "" {
image = img
}
if podSecurityContext == nil {
podSecurityContext = &corev1.PodSecurityContext{
RunAsNonRoot: ptrOf(true),
}
}
if containerSecurityContext == nil {
containerSecurityContext = &corev1.SecurityContext{
Privileged: ptrOf(false),
RunAsUser: ptrOf(int64(1000)),
RunAsGroup: ptrOf(int64(1000)),
RunAsNonRoot: ptrOf(true),
AllowPrivilegeEscalation: ptrOf(false),
ReadOnlyRootFilesystem: ptrOf(true),
Capabilities: &corev1.Capabilities{
Drop: []corev1.Capability{
"ALL",
},
},
}
}
databaseDSN, err := core.Spec.Database.GetDSN(ctx, namespacedClient)
if err != nil {
return err
}
parsedDSN, err := url.Parse(databaseDSN)
if err != nil {
return fmt.Errorf("failed to parse DB DSN: %w", err)
}
_, err = controllerutil.CreateOrUpdate(ctx, r.Client, authDeployment, func() error {
authDeployment.Labels = MergeLabels(
objectLabels(core, "auth", "core", supabase.Images.Gotrue.Tag),
core.Labels,
)
authDbEnv := []corev1.EnvVar{
{
Name: "POSTGRES_PASSWORD",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Name: core.Spec.Database.Roles.Secrets.AuthAdmin.Name,
},
Key: corev1.BasicAuthPasswordKey,
},
},
},
{
Name: svcCfg.EnvKeys.DatabaseUrl,
Value: strings.TrimSuffix(fmt.Sprintf("postgres://%s:$(POSTGRES_PASSWORD)@%s%s?%s", supabase.DBRoleAuthAdmin, parsedDSN.Host, parsedDSN.Path, parsedDSN.Query().Encode()), "?"),
},
}
authEnv := append(authDbEnv,
svcCfg.EnvKeys.ApiHost.Var(svcCfg.Defaults.ApiHost),
svcCfg.EnvKeys.ApiPort.Var(svcCfg.Defaults.ApiPort),
svcCfg.EnvKeys.ApiExternalUrl.Var(authSpec.APIExternalURL),
svcCfg.EnvKeys.DBDriver.Var(svcCfg.Defaults.DbDriver),
svcCfg.EnvKeys.SiteUrl.Var(authSpec.SiteURL),
svcCfg.EnvKeys.AdditionalRedirectURLs.Var(authSpec.AdditionalRedirectUrls),
svcCfg.EnvKeys.DisableSignup.Var(boolValueOf(authSpec.DisableSignup)),
svcCfg.EnvKeys.JWTIssuer.Var(svcCfg.Defaults.JwtIssuer),
svcCfg.EnvKeys.JWTAdminRoles.Var(svcCfg.Defaults.JwtAdminRoles),
svcCfg.EnvKeys.JWTAudience.Var(svcCfg.Defaults.JwtAudience),
svcCfg.EnvKeys.JwtDefaultGroup.Var(svcCfg.Defaults.JwtDefaultGroupName),
svcCfg.EnvKeys.JwtExpiry.Var(ValueOrFallback(core.Spec.JWT.Expiry, supabase.ServiceConfig.JWT.Defaults.Expiry)),
svcCfg.EnvKeys.JwtSecret.Var(core.Spec.JWT.SecretKeySelector()),
svcCfg.EnvKeys.EmailSignupDisabled.Var(boolValueOf(authSpec.EmailSignupDisabled)),
svcCfg.EnvKeys.AnonymousUsersEnabled.Var(boolValueOf(authSpec.AnonymousUsersEnabled)),
)
authEnv = append(authEnv, authSpec.Providers.Vars(authSpec.APIExternalURL)...)
if authDeployment.CreationTimestamp.IsZero() {
authDeployment.Spec.Selector = &metav1.LabelSelector{
MatchLabels: selectorLabels(core, "auth"),
}
}
authDeployment.Spec.Replicas = authSpec.WorkloadTemplate.Replicas
authDeployment.Spec.Template = corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: objectLabels(core, "auth", "core", supabase.Images.Gotrue.Tag),
},
Spec: corev1.PodSpec{
ImagePullSecrets: authSpec.WorkloadTemplate.Workload.ImagePullSecrets,
InitContainers: []corev1.Container{{
Name: "migrations",
Image: image,
ImagePullPolicy: pullPolicy,
Command: []string{"/usr/local/bin/auth"},
Args: []string{"migrate"},
Env: authEnv,
SecurityContext: containerSecurityContext,
}},
Containers: []corev1.Container{{
Name: "supabase-auth",
Image: image,
ImagePullPolicy: pullPolicy,
Command: []string{"/usr/local/bin/auth"},
Args: []string{"serve"},
Env: MergeEnv(authEnv, authSpec.WorkloadTemplate.Workload.AdditionalEnv...),
Ports: []corev1.ContainerPort{{
Name: "api",
ContainerPort: 9999,
Protocol: corev1.ProtocolTCP,
}},
SecurityContext: containerSecurityContext,
Resources: authSpec.WorkloadTemplate.Workload.Resources,
VolumeMounts: authSpec.WorkloadTemplate.Workload.VolumeMounts,
ReadinessProbe: &corev1.Probe{
InitialDelaySeconds: 5,
PeriodSeconds: 3,
TimeoutSeconds: 1,
SuccessThreshold: 2,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Port: intstr.IntOrString{IntVal: 9999},
},
},
},
LivenessProbe: &corev1.Probe{
InitialDelaySeconds: 10,
PeriodSeconds: 5,
TimeoutSeconds: 3,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Port: intstr.IntOrString{IntVal: 9999},
},
},
},
}},
SecurityContext: podSecurityContext,
},
}
if err := controllerutil.SetControllerReference(core, authDeployment, r.Scheme); err != nil {
return err
}
return nil
})
return err
}
func (r *CoreAuthReconciler) reconcileAuthService(
ctx context.Context,
core *supabasev1alpha1.Core,
) error {
authService := &corev1.Service{
ObjectMeta: supabase.ServiceConfig.Auth.ObjectMeta(core),
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, authService, func() error {
authService.Labels = MergeLabels(
objectLabels(core, "auth", "core", supabase.Images.Gotrue.Tag),
core.Labels,
)
authService.Labels[meta.SupabaseLabel.EnvoyCluster] = core.Name
authService.Spec = corev1.ServiceSpec{
Selector: selectorLabels(core, "auth"),
Ports: []corev1.ServicePort{
{
Name: "api",
Protocol: corev1.ProtocolTCP,
AppProtocol: ptrOf("http"),
Port: 9999,
TargetPort: intstr.IntOrString{IntVal: 9999},
},
},
}
if err := controllerutil.SetControllerReference(core, authService, r.Scheme); err != nil {
return err
}
return nil
})
return err
}

View file

@ -0,0 +1,166 @@
/*
Copyright 2024 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 controller
import (
"context"
"encoding/json"
"fmt"
"maps"
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
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/log"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/jwk"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
// CoreDbReconciler reconciles a Core object
type CoreJwtReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *CoreJwtReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) {
var (
core supabasev1alpha1.Core
logger = log.FromContext(ctx)
)
if err := r.Get(ctx, req.NamespacedName, &core); client.IgnoreNotFound(err) != nil {
logger.Error(err, "unable to fetch Core")
return ctrl.Result{}, err
}
jwtSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: core.Spec.JWT.SecretRef.Name, Namespace: core.Namespace},
}
_, err = controllerutil.CreateOrUpdate(ctx, r.Client, jwtSecret, func() error {
const (
secretJwksAndDefaultJWTs = 4
)
var modifiedSecret bool
jwtSecret.Labels = maps.Clone(core.Labels)
if jwtSecret.Labels == nil {
jwtSecret.Labels = make(map[string]string)
}
jwtSecret.Labels[meta.SupabaseLabel.Reload] = ""
if err := controllerutil.SetControllerReference(&core, jwtSecret, r.Scheme); err != nil {
return err
}
if jwtSecret.Data == nil {
jwtSecret.Data = make(map[string][]byte, secretJwksAndDefaultJWTs)
}
// if secret does not contain the JWT secret as configured
if value := jwtSecret.Data[core.Spec.JWT.SecretKey]; len(value) == 0 {
logger.Info("Generating new JWT secret")
generatedSecret, err := supabase.RandomJWTSecret()
if err != nil {
return err
}
jwtSecret.Data[core.Spec.JWT.SecretKey] = generatedSecret
modifiedSecret = true
}
if value := jwtSecret.Data[core.Spec.JWT.JwksKey]; len(value) == 0 || modifiedSecret {
keySet := jwk.Set[jwk.SymmetricKey]{
Keys: []jwk.SymmetricKey{{
Algorithm: jwk.AlgorithmHS256,
Key: jwtSecret.Data[core.Spec.JWT.SecretKey],
}},
}
serializedKeySet, err := json.Marshal(keySet)
if err != nil {
return fmt.Errorf("marshalling JWKS: %w", err)
}
jwtSecret.Data[core.Spec.JWT.JwksKey] = serializedKeySet
}
if value := jwtSecret.Data[core.Spec.JWT.AnonKey]; len(value) == 0 || modifiedSecret {
anonKey, err := generateJwt("anon", jwtSecret.Data[core.Spec.JWT.SecretKey])
if err != nil {
return err
}
jwtSecret.Data[core.Spec.JWT.AnonKey] = anonKey
}
if value := jwtSecret.Data[core.Spec.JWT.ServiceKey]; len(value) == 0 || modifiedSecret {
serviceKey, err := generateJwt("service_role", jwtSecret.Data[core.Spec.JWT.SecretKey])
if err != nil {
return err
}
jwtSecret.Data[core.Spec.JWT.ServiceKey] = serviceKey
}
return nil
})
if err != nil {
return ctrl.Result{}, nil
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *CoreJwtReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(new(supabasev1alpha1.Core)).
Owns(new(corev1.Secret)).
Named("core-jwt").
Complete(r)
}
func generateJwt(role string, secret []byte) ([]byte, error) {
claims := map[string]any{
"role": role,
jwt.IssuerKey: "supabase",
jwt.IssuedAtKey: time.Now().Add(-30 * time.Second),
jwt.ExpirationKey: time.Now().Add(365 * 24 * time.Hour),
}
token := jwt.New()
for k, v := range claims {
if err := token.Set(k, v); err != nil {
return nil, err
}
}
return jwt.Sign(token, jwt.WithKey(jwa.HS256, secret))
}

View file

@ -0,0 +1,289 @@
package controller
import (
"context"
"encoding/hex"
"fmt"
"net/url"
"strings"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr"
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/log"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
type CorePostgrestReconiler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *CorePostgrestReconiler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) {
var (
core supabasev1alpha1.Core
logger = log.FromContext(ctx)
)
if err := r.Get(ctx, req.NamespacedName, &core); client.IgnoreNotFound(err) != nil {
logger.Error(err, "unable to fetch Core")
return ctrl.Result{}, err
}
if err := r.reconilePostgrestDeployment(ctx, &core); err != nil {
if client.IgnoreNotFound(err) == nil {
logger.Error(err, "expected resource does not exist (yet), waiting for it to be present")
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
return ctrl.Result{}, err
}
if err := r.reconcilePostgrestService(ctx, &core); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *CorePostgrestReconiler) SetupWithManager(mgr ctrl.Manager) error {
// TODO watch changes in DB credentials secret
return ctrl.NewControllerManagedBy(mgr).
For(new(supabasev1alpha1.Core)).
Owns(new(appsv1.Deployment)).
Owns(new(corev1.Service)).
Named("core-postgrest").
Complete(r)
}
func (r *CorePostgrestReconiler) reconilePostgrestDeployment(
ctx context.Context,
core *supabasev1alpha1.Core,
) error {
var (
serviceCfg = supabase.ServiceConfig.Postgrest
postgrestDeployment = &appsv1.Deployment{
ObjectMeta: serviceCfg.ObjectMeta(core),
}
postgrestSpec = core.Spec.Postgrest
)
if postgrestSpec.WorkloadTemplate == nil {
postgrestSpec.WorkloadTemplate = new(supabasev1alpha1.WorkloadTemplate)
}
if postgrestSpec.WorkloadTemplate.Workload == nil {
postgrestSpec.WorkloadTemplate.Workload = new(supabasev1alpha1.ContainerTemplate)
}
var (
image = supabase.Images.Postgrest.String()
podSecurityContext = postgrestSpec.WorkloadTemplate.SecurityContext
pullPolicy = postgrestSpec.WorkloadTemplate.Workload.PullPolicy
containerSecurityContext = postgrestSpec.WorkloadTemplate.Workload.SecurityContext
anonRole = ValueOrFallback(postgrestSpec.AnonRole, serviceCfg.Defaults.AnonRole)
postgrestSchemas = ValueOrFallback(postgrestSpec.Schemas, serviceCfg.Defaults.Schemas)
jwtSecretHash string
namespacedClient = client.NewNamespacedClient(r.Client, core.Namespace)
)
if img := postgrestSpec.WorkloadTemplate.Workload.Image; img != "" {
image = img
}
if podSecurityContext == nil {
podSecurityContext = &corev1.PodSecurityContext{
RunAsNonRoot: ptrOf(true),
}
}
if containerSecurityContext == nil {
containerSecurityContext = &corev1.SecurityContext{
Privileged: ptrOf(false),
RunAsUser: ptrOf(int64(1000)),
RunAsGroup: ptrOf(int64(1000)),
RunAsNonRoot: ptrOf(true),
AllowPrivilegeEscalation: ptrOf(false),
ReadOnlyRootFilesystem: ptrOf(true),
Capabilities: &corev1.Capabilities{
Drop: []corev1.Capability{
"ALL",
},
},
}
}
databaseDSN, err := core.Spec.Database.GetDSN(ctx, namespacedClient)
if err != nil {
return err
}
parsedDSN, err := url.Parse(databaseDSN)
if err != nil {
return fmt.Errorf("failed to parse DB DSN: %w", err)
}
if jwtSecret, err := core.Spec.JWT.GetJWTSecret(ctx, namespacedClient); err != nil {
return err
} else {
jwtSecretHash = hex.EncodeToString(HashBytes(jwtSecret))
}
_, err = controllerutil.CreateOrUpdate(ctx, r.Client, postgrestDeployment, func() error {
postgrestDeployment.Labels = MergeLabels(
objectLabels(core, serviceCfg.Name, "core", supabase.Images.Postgrest.Tag),
core.Labels,
)
postgrestEnv := []corev1.EnvVar{
{
Name: "DB_CREDENTIALS_PASSWORD",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Name: core.Spec.Database.Roles.Secrets.Authenticator.Name,
},
Key: corev1.BasicAuthPasswordKey,
},
},
},
{
Name: serviceCfg.EnvKeys.DBUri,
Value: strings.TrimSuffix(fmt.Sprintf("postgres://%s:$(DB_CREDENTIALS_PASSWORD)@%s%s?%s", supabase.DBRoleAuthenticator, parsedDSN.Host, parsedDSN.Path, parsedDSN.Query().Encode()), "?"),
},
serviceCfg.EnvKeys.JWTSecret.Var(core.Spec.JWT.JwksKeySelector()),
serviceCfg.EnvKeys.Schemas.Var(postgrestSchemas),
serviceCfg.EnvKeys.AnonRole.Var(anonRole),
serviceCfg.EnvKeys.UseLegacyGucs.Var(false),
serviceCfg.EnvKeys.ExtraSearchPath.Var(serviceCfg.Defaults.ExtraSearchPath),
serviceCfg.EnvKeys.AppSettingsJWTSecret.Var(core.Spec.JWT.SecretKeySelector()),
serviceCfg.EnvKeys.AppSettingsJWTExpiry.Var(ValueOrFallback(core.Spec.JWT.Expiry, supabase.ServiceConfig.JWT.Defaults.Expiry)),
serviceCfg.EnvKeys.AdminServerPort.Var(3001),
}
if postgrestDeployment.CreationTimestamp.IsZero() {
postgrestDeployment.Spec.Selector = &metav1.LabelSelector{
MatchLabels: selectorLabels(core, serviceCfg.Name),
}
}
postgrestDeployment.Spec.Replicas = postgrestSpec.WorkloadTemplate.Replicas
postgrestDeployment.Spec.Template = corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
fmt.Sprintf("%s/%s", supabasev1alpha1.GroupVersion.Group, "jwt-hash"): jwtSecretHash,
},
Labels: objectLabels(core, serviceCfg.Name, "core", supabase.Images.Postgrest.Tag),
},
Spec: corev1.PodSpec{
ImagePullSecrets: postgrestSpec.WorkloadTemplate.Workload.ImagePullSecrets,
Containers: []corev1.Container{
{
Name: "supabase-rest",
Image: image,
ImagePullPolicy: pullPolicy,
Args: []string{"postgrest"},
Env: MergeEnv(postgrestEnv, postgrestSpec.WorkloadTemplate.Workload.AdditionalEnv...),
Ports: []corev1.ContainerPort{
{
Name: "rest",
ContainerPort: 3000,
Protocol: corev1.ProtocolTCP,
},
{
Name: "admin",
ContainerPort: 3001,
Protocol: corev1.ProtocolTCP,
},
},
SecurityContext: containerSecurityContext,
Resources: postgrestSpec.WorkloadTemplate.Workload.Resources,
VolumeMounts: postgrestSpec.WorkloadTemplate.Workload.VolumeMounts,
ReadinessProbe: &corev1.Probe{
InitialDelaySeconds: 5,
PeriodSeconds: 3,
TimeoutSeconds: 1,
SuccessThreshold: 2,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/ready",
Port: intstr.IntOrString{IntVal: 3001},
},
},
},
LivenessProbe: &corev1.Probe{
InitialDelaySeconds: 10,
PeriodSeconds: 5,
TimeoutSeconds: 3,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/live",
Port: intstr.IntOrString{IntVal: 3001},
},
},
},
},
},
SecurityContext: podSecurityContext,
},
}
if err := controllerutil.SetControllerReference(core, postgrestDeployment, r.Scheme); err != nil {
return err
}
return nil
})
return err
}
func (r *CorePostgrestReconiler) reconcilePostgrestService(
ctx context.Context,
core *supabasev1alpha1.Core,
) error {
postgrestService := &corev1.Service{
ObjectMeta: supabase.ServiceConfig.Postgrest.ObjectMeta(core),
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, postgrestService, func() error {
postgrestService.Labels = MergeLabels(
objectLabels(core, supabase.ServiceConfig.Postgrest.Name, "core", supabase.Images.Postgrest.Tag),
core.Labels,
)
postgrestService.Labels[meta.SupabaseLabel.EnvoyCluster] = core.Name
postgrestService.Spec = corev1.ServiceSpec{
Selector: selectorLabels(core, supabase.ServiceConfig.Postgrest.Name),
Ports: []corev1.ServicePort{
{
Name: "rest",
Protocol: corev1.ProtocolTCP,
AppProtocol: ptrOf("http"),
Port: 3000,
TargetPort: intstr.IntOrString{IntVal: 3000},
},
},
}
if err := controllerutil.SetControllerReference(core, postgrestService, r.Scheme); err != nil {
return err
}
return nil
})
return err
}

View file

@ -0,0 +1,84 @@
/*
Copyright 2024 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 controller
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
)
var _ = Describe("Dashboard Controller", func() {
Context("When reconciling a resource", func() {
const resourceName = "test-resource"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default", // TODO(user):Modify as needed
}
dashboard := &supabasev1alpha1.Dashboard{}
BeforeEach(func() {
By("creating the custom resource for the Kind Dashboard")
err := k8sClient.Get(ctx, typeNamespacedName, dashboard)
if err != nil && errors.IsNotFound(err) {
resource := &supabasev1alpha1.Dashboard{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: "default",
},
// TODO(user): Specify other spec details if needed.
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
}
})
AfterEach(func() {
// TODO(user): Cleanup logic after each test, like removing the resource instance.
resource := &supabasev1alpha1.Dashboard{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
Expect(err).NotTo(HaveOccurred())
By("Cleanup the specific resource instance Dashboard")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
})
It("should successfully reconcile the resource", func() {
By("Reconciling the created resource")
controllerReconciler := &DashboardReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
}
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())
// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
// Example: If you expect a certain status condition after reconciliation, verify it here.
})
})
})

View file

@ -0,0 +1,273 @@
/*
Copyright 2024 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 controller
import (
"context"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr"
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/log"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
// DashboardPGMetaReconciler reconciles a Dashboard object
type DashboardPGMetaReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=dashboards,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=dashboards/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=dashboards/finalizers,verbs=update
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the Dashboard object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/reconcile
func (r *DashboardPGMetaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var (
dashboard supabasev1alpha1.Dashboard
logger = log.FromContext(ctx)
)
if err := r.Get(ctx, req.NamespacedName, &dashboard); client.IgnoreNotFound(err) != nil {
logger.Error(err, "unable to fetch Dashboard")
return ctrl.Result{}, err
}
if err := r.reconcilePGMetaDeployment(ctx, &dashboard); err != nil {
return ctrl.Result{}, err
}
if err := r.reconcilePGMetaService(ctx, &dashboard); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *DashboardPGMetaReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&supabasev1alpha1.Dashboard{}).
Owns(new(appsv1.Deployment)).
Owns(new(corev1.Service)).
Named("dashboard-pgmeta").
Complete(r)
}
func (r *DashboardPGMetaReconciler) reconcilePGMetaDeployment(
ctx context.Context,
dashboard *supabasev1alpha1.Dashboard,
) error {
var (
serviceCfg = supabase.ServiceConfig.PGMeta
pgMetaDeployment = &appsv1.Deployment{
ObjectMeta: serviceCfg.ObjectMeta(dashboard),
}
pgMetaSpec = dashboard.Spec.PGMeta
)
if pgMetaSpec == nil {
pgMetaSpec = new(supabasev1alpha1.PGMetaSpec)
}
if pgMetaSpec.WorkloadTemplate == nil {
pgMetaSpec.WorkloadTemplate = new(supabasev1alpha1.WorkloadTemplate)
}
if pgMetaSpec.WorkloadTemplate.Workload == nil {
pgMetaSpec.WorkloadTemplate.Workload = new(supabasev1alpha1.ContainerTemplate)
}
var (
image = supabase.Images.PostgresMeta.String()
podSecurityContext = pgMetaSpec.WorkloadTemplate.SecurityContext
pullPolicy = pgMetaSpec.WorkloadTemplate.Workload.PullPolicy
containerSecurityContext = pgMetaSpec.WorkloadTemplate.Workload.SecurityContext
)
if img := pgMetaSpec.WorkloadTemplate.Workload.Image; img != "" {
image = img
}
if podSecurityContext == nil {
podSecurityContext = &corev1.PodSecurityContext{
RunAsNonRoot: ptrOf(true),
}
}
if containerSecurityContext == nil {
containerSecurityContext = &corev1.SecurityContext{
Privileged: ptrOf(false),
RunAsUser: ptrOf(int64(1000)),
RunAsGroup: ptrOf(int64(1000)),
RunAsNonRoot: ptrOf(true),
AllowPrivilegeEscalation: ptrOf(false),
ReadOnlyRootFilesystem: ptrOf(true),
Capabilities: &corev1.Capabilities{
Drop: []corev1.Capability{
"ALL",
},
},
}
}
dsnSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: dashboard.Spec.DBSpec.DBCredentialsRef.Name,
Namespace: dashboard.Namespace,
},
}
if err := r.Get(ctx, client.ObjectKeyFromObject(dsnSecret), dsnSecret); err != nil {
return err
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, pgMetaDeployment, func() error {
pgMetaDeployment.Labels = MergeLabels(
objectLabels(dashboard, serviceCfg.Name, "dashboard", supabase.Images.PostgresMeta.Tag),
dashboard.Labels,
)
if pgMetaDeployment.CreationTimestamp.IsZero() {
pgMetaDeployment.Spec.Selector = &metav1.LabelSelector{
MatchLabels: selectorLabels(dashboard, serviceCfg.Name),
}
}
pgMetaDeployment.Spec.Replicas = pgMetaSpec.WorkloadTemplate.Replicas
pgMetaEnv := []corev1.EnvVar{
serviceCfg.EnvKeys.APIPort.Var(serviceCfg.Defaults.APIPort),
serviceCfg.EnvKeys.DBHost.Var(dashboard.Spec.DBSpec.Host),
serviceCfg.EnvKeys.DBPort.Var(dashboard.Spec.DBSpec.Port),
serviceCfg.EnvKeys.DBUser.Var(dashboard.Spec.DBSpec.UserRef()),
serviceCfg.EnvKeys.DBPassword.Var(dashboard.Spec.DBSpec.PasswordRef()),
}
pgMetaDeployment.Spec.Template = corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: objectLabels(dashboard, serviceCfg.Name, "dashboard", supabase.Images.PostgresMeta.Tag),
},
Spec: corev1.PodSpec{
ImagePullSecrets: pgMetaSpec.WorkloadTemplate.Workload.ImagePullSecrets,
Containers: []corev1.Container{{
Name: "supabase-meta",
Image: image,
ImagePullPolicy: pullPolicy,
Env: MergeEnv(pgMetaEnv, pgMetaSpec.WorkloadTemplate.Workload.AdditionalEnv...),
Ports: []corev1.ContainerPort{{
Name: "api",
ContainerPort: int32(serviceCfg.Defaults.APIPort),
Protocol: corev1.ProtocolTCP,
}},
SecurityContext: containerSecurityContext,
Resources: pgMetaSpec.WorkloadTemplate.Workload.Resources,
VolumeMounts: pgMetaSpec.WorkloadTemplate.Workload.VolumeMounts,
ReadinessProbe: &corev1.Probe{
InitialDelaySeconds: 5,
PeriodSeconds: 3,
TimeoutSeconds: 1,
SuccessThreshold: 2,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Port: intstr.IntOrString{IntVal: int32(serviceCfg.Defaults.APIPort)},
},
},
},
LivenessProbe: &corev1.Probe{
InitialDelaySeconds: 10,
PeriodSeconds: 5,
TimeoutSeconds: 3,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Port: intstr.IntOrString{IntVal: int32(serviceCfg.Defaults.APIPort)},
},
},
},
}},
SecurityContext: podSecurityContext,
},
}
if err := controllerutil.SetControllerReference(dashboard, pgMetaDeployment, r.Scheme); err != nil {
return err
}
return nil
})
return err
}
func (r *DashboardPGMetaReconciler) reconcilePGMetaService(
ctx context.Context,
dashboard *supabasev1alpha1.Dashboard,
) error {
pgMetaService := &corev1.Service{
ObjectMeta: supabase.ServiceConfig.PGMeta.ObjectMeta(dashboard),
}
_, err := controllerutil.CreateOrPatch(ctx, r.Client, pgMetaService, func() error {
pgMetaService.Labels = MergeLabels(
objectLabels(dashboard, supabase.ServiceConfig.PGMeta.Name, "dashboard", supabase.Images.PostgresMeta.Tag),
dashboard.Labels,
)
pgMetaService.Labels[meta.SupabaseLabel.EnvoyCluster] = dashboard.Name
apiPort := int32(supabase.ServiceConfig.PGMeta.Defaults.APIPort)
pgMetaService.Spec = corev1.ServiceSpec{
Selector: selectorLabels(dashboard, supabase.ServiceConfig.PGMeta.Name),
Ports: []corev1.ServicePort{
{
Name: "api",
Protocol: corev1.ProtocolTCP,
AppProtocol: ptrOf("http"),
Port: apiPort,
TargetPort: intstr.IntOrString{IntVal: apiPort},
},
},
}
if err := controllerutil.SetControllerReference(dashboard, pgMetaService, r.Scheme); err != nil {
return err
}
return nil
})
return err
}

View file

@ -0,0 +1,41 @@
package controller
import (
"maps"
"sigs.k8s.io/controller-runtime/pkg/client"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
)
func selectorLabels(
object client.Object,
name string,
) map[string]string {
return map[string]string{
meta.WellKnownLabel.Name: name,
meta.WellKnownLabel.Instance: object.GetName(),
meta.WellKnownLabel.PartOf: "supabase",
}
}
func objectLabels(
object client.Object,
name,
component,
version string,
) map[string]string {
labels := maps.Clone(object.GetLabels())
if labels == nil {
labels = make(map[string]string, 6)
}
labels[meta.WellKnownLabel.Name] = name
labels[meta.WellKnownLabel.Instance] = object.GetName()
labels[meta.WellKnownLabel.Version] = version
labels[meta.WellKnownLabel.Component] = component
labels[meta.WellKnownLabel.PartOf] = "supabase"
labels[meta.WellKnownLabel.ManagedBy] = "supabase-operator"
return labels
}

View file

@ -0,0 +1,11 @@
package controller
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=cores,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=cores/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=cores/finalizers,verbs=update
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=apigateways,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=apigateways/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=supabase.k8s.icb4dc0.de,resources=apigateways/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=secrets;configmaps;services,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=events,verbs=create

View file

@ -0,0 +1,33 @@
package controller
import (
"bytes"
"math/rand/v2"
)
func GeneratePW(length uint, random *rand.Rand) []byte {
var (
builder = bytes.NewBuffer(nil)
alphabet = runes('a', 'z') + runes('A', 'Z') + runes('0', '9')
)
if random == nil {
random = rand.New(rand.NewPCG(0, 0))
}
for range length {
builder.WriteRune(rune(alphabet[random.IntN(len(alphabet))]))
}
return builder.Bytes()
}
func runes(start, end rune) string {
result := make([]rune, 0, int(end-start))
for current := start; current != end; current++ {
result = append(result, current)
}
return string(result)
}

View file

@ -0,0 +1,39 @@
node:
cluster: {{ .Node.Cluster }}
id: {{ .Node.ID }}
dynamic_resources:
ads_config:
api_type: GRPC
grpc_services:
- envoy_grpc:
cluster_name: {{ .ControlPlane.Name }}
cds_config:
ads: {}
lds_config:
ads: {}
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 }}
load_assignment:
cluster_name: {{ .ControlPlane.Name }}
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: {{ .ControlPlane.Host }}
port_value: {{ .ControlPlane.Port }}
admin:
address:
socket_address:
address: 0.0.0.0
port_value: 19000

View file

@ -0,0 +1,145 @@
package controller
import (
"context"
"crypto/sha256"
"errors"
"io"
"maps"
"reflect"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"code.icb4dc0.de/prskr/supabase-operator/api"
)
func Close(closer io.Closer, err *error) {
*err = errors.Join(*err, closer.Close())
}
func CloseCtx(ctx context.Context, closable interface {
Close(ctx context.Context) error
}, err *error,
) {
*err = errors.Join(*err, closable.Close(ctx))
}
func ptrOf[T any](val T) *T {
return &val
}
func boolValueOf(ptr *bool) bool {
if ptr == nil {
return false
}
return *ptr
}
func MergeLabels(source map[string]string, toAppend ...map[string]string) map[string]string {
result := make(map[string]string, len(source)+len(toAppend))
maps.Copy(result, source)
for _, additionalLabels := range toAppend {
for k, v := range additionalLabels {
if _, exists := result[k]; exists {
continue
}
result[k] = v
}
}
return result
}
func MergeEnv(source []corev1.EnvVar, toAppend ...corev1.EnvVar) []corev1.EnvVar {
existingKeys := make(map[string]bool, len(source)+len(toAppend))
merged := append(make([]corev1.EnvVar, 0, len(source)+len(toAppend)), source...)
for _, v := range source {
existingKeys[v.Name] = true
}
for _, v := range toAppend {
if _, alreadyPresent := existingKeys[v.Name]; alreadyPresent {
continue
}
merged = append(merged, v)
existingKeys[v.Name] = true
}
return merged
}
func ValueOrFallback[T any](value, fallback T) T {
rval := reflect.ValueOf(value)
if rval.IsZero() {
return fallback
}
return value
}
func HashStrings(vals ...string) []byte {
h := sha256.New()
for _, v := range vals {
h.Write([]byte(v))
}
return h.Sum(nil)
}
func HashBytes(vals ...[]byte) []byte {
h := sha256.New()
for _, v := range vals {
h.Write(v)
}
return h.Sum(nil)
}
func FieldSelectorEventHandler[TItem metav1.Object, TList api.ObjectList[TItem]](
cli client.Client,
fieldSelector string,
) handler.TypedEventHandler[client.Object, reconcile.Request] {
return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
var (
list TList
selectorInstance = client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(fieldSelector, obj.GetName()),
}
logger = log.FromContext(ctx, "object", obj.GetName(), "namespace", obj.GetNamespace())
)
list = reflect.New(reflect.TypeOf(list).Elem()).Interface().(TList)
if err := cli.List(ctx, list, selectorInstance, client.InNamespace(obj.GetNamespace())); err != nil {
logger.Error(err, "could not list items for field selector event handler")
return nil
}
requests := make([]reconcile.Request, 0)
for item := range list.Iter() {
requests = append(requests, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: item.GetName(),
Namespace: obj.GetNamespace(),
},
})
}
return requests
})
}

View file

@ -0,0 +1,209 @@
package controlplane
import (
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
rbacv3cfg "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v3"
jwtauthnv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/jwt_authn/v3"
rbacv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3"
matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
)
const (
JwtProviderName = "supabase"
JwtMetadataKey = "supabase-jwt"
JwtAuthenticatedRequirement = "supabase-jwt-authenticated"
)
type ForwardJwt bool
func (j ForwardJwt) Apply(opts *JwtOptions) {
opts.ForwardJwt = bool(j)
}
type JwtFilterOption interface {
Apply(opts *JwtOptions)
}
type JwtFilterOptionFunc func(opts *JwtOptions)
func (f JwtFilterOptionFunc) Apply(opts *JwtOptions) {
f(opts)
}
type JwtOptions struct {
ForwardJwt bool
ForwardHeader string
}
func JWTPerRouteConfig() *jwtauthnv3.PerRouteConfig {
return &jwtauthnv3.PerRouteConfig{
RequirementSpecifier: &jwtauthnv3.PerRouteConfig_RequirementName{
RequirementName: JwtAuthenticatedRequirement,
},
}
}
func JWTAllowAll() *jwtauthnv3.PerRouteConfig {
return &jwtauthnv3.PerRouteConfig{
RequirementSpecifier: &jwtauthnv3.PerRouteConfig_Disabled{
Disabled: true,
},
}
}
func JWTFilterConfig(opts ...JwtFilterOption) *jwtauthnv3.JwtAuthentication {
const (
issuerName = "supabase"
bearerTokenPrefix = "Bearer "
apiKeyParamKey = "apikey"
authorizationHeaderKey = "Authorization"
)
filterOpts := &JwtOptions{
ForwardJwt: true,
}
for _, o := range opts {
o.Apply(filterOpts)
}
return &jwtauthnv3.JwtAuthentication{
Providers: map[string]*jwtauthnv3.JwtProvider{
JwtProviderName: {
Issuer: issuerName,
PayloadInMetadata: JwtMetadataKey,
JwksSourceSpecifier: &jwtauthnv3.JwtProvider_LocalJwks{
LocalJwks: &corev3.DataSource{
Specifier: &corev3.DataSource_Filename{
Filename: "/etc/envoy/jwks.json",
},
WatchedDirectory: &corev3.WatchedDirectory{
Path: "/etc/envoy",
},
},
},
Forward: filterOpts.ForwardJwt,
FromHeaders: []*jwtauthnv3.JwtHeader{
{
Name: apiKeyParamKey,
},
{
Name: authorizationHeaderKey,
ValuePrefix: bearerTokenPrefix,
},
},
FromParams: []string{apiKeyParamKey},
RequireExpiration: true,
},
},
BypassCorsPreflight: true,
RequirementMap: map[string]*jwtauthnv3.JwtRequirement{
JwtAuthenticatedRequirement: {
RequiresType: &jwtauthnv3.JwtRequirement_ProviderName{
ProviderName: JwtProviderName,
},
},
},
}
}
func RBACPerRoute(cfg *rbacv3.RBAC) *rbacv3.RBACPerRoute {
return &rbacv3.RBACPerRoute{Rbac: cfg}
}
func RBACAllowAllConfig() *rbacv3.RBAC {
return &rbacv3.RBAC{
Rules: &rbacv3cfg.RBAC{
Action: rbacv3cfg.RBAC_ALLOW,
Policies: map[string]*rbacv3cfg.Policy{
"Allow anyone": {
Permissions: []*rbacv3cfg.Permission{{
Rule: &rbacv3cfg.Permission_Any{Any: true},
}},
Principals: []*rbacv3cfg.Principal{{
Identifier: &rbacv3cfg.Principal_Any{
Any: true,
},
}},
},
},
},
}
}
func RBACRequireAuthConfig() *rbacv3.RBAC {
return &rbacv3.RBAC{
Rules: &rbacv3cfg.RBAC{
Action: rbacv3cfg.RBAC_ALLOW,
Policies: map[string]*rbacv3cfg.Policy{
"allow admin and anon roles": {
Permissions: []*rbacv3cfg.Permission{{
Rule: &rbacv3cfg.Permission_Any{Any: true},
}},
Principals: []*rbacv3cfg.Principal{{
Identifier: &rbacv3cfg.Principal_OrIds{
OrIds: &rbacv3cfg.Principal_Set{
Ids: []*rbacv3cfg.Principal{
{
Identifier: &rbacv3cfg.Principal_Metadata{
Metadata: &matcherv3.MetadataMatcher{
Filter: FilterNameJwtAuthn,
Path: []*matcherv3.MetadataMatcher_PathSegment{
{
Segment: &matcherv3.MetadataMatcher_PathSegment_Key{
Key: "jwt_payload",
},
},
{
Segment: &matcherv3.MetadataMatcher_PathSegment_Key{
Key: "role",
},
},
},
Value: &matcherv3.ValueMatcher{
MatchPattern: &matcherv3.ValueMatcher_OrMatch{
OrMatch: &matcherv3.OrMatcher{
ValueMatchers: []*matcherv3.ValueMatcher{
{
MatchPattern: &matcherv3.ValueMatcher_StringMatch{
StringMatch: &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_Exact{
Exact: "anon",
},
},
},
},
{
MatchPattern: &matcherv3.ValueMatcher_StringMatch{
StringMatch: &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_Exact{
Exact: "authenticated",
},
},
},
},
{
MatchPattern: &matcherv3.ValueMatcher_StringMatch{
StringMatch: &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_Exact{
Exact: "admin",
},
},
},
},
},
},
},
},
},
},
},
},
},
},
}},
},
},
},
}
}

View file

@ -0,0 +1,327 @@
/*
Copyright 2024 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 (
"context"
"errors"
"fmt"
"slices"
"strconv"
"sync"
"time"
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
listener "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"
hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/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/types/known/anypb"
discoveryv1 "k8s.io/api/discovery/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apimachinery/pkg/watch"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
var (
ErrUnexpectedObject = errors.New("unexpected object")
ErrNoEnvoyClusterLabel = errors.New("no Envoy cluster label set")
supabaseServices = []string{
supabase.ServiceConfig.Postgrest.Name,
supabase.ServiceConfig.Auth.Name,
supabase.ServiceConfig.PGMeta.Name,
}
)
type EndpointsController struct {
lock sync.Mutex
Client client.WithWatch
Cache cache.SnapshotCache
envoyClusters map[string]*envoyClusterServices
}
func (c *EndpointsController) Run(ctx context.Context) error {
var (
logger = ctrl.Log.WithName("endpoints-controller")
endpointSlices discoveryv1.EndpointSliceList
)
selector := labels.NewSelector()
partOfRequirement, err := labels.NewRequirement(meta.WellKnownLabel.PartOf, selection.Equals, []string{"supabase"})
if err != nil {
return fmt.Errorf("preparing watcher selectors: %w", err)
}
nameRequirement, err := labels.NewRequirement(meta.WellKnownLabel.Name, selection.In, supabaseServices)
if err != nil {
return fmt.Errorf("preparing watcher selectors: %w", err)
}
envoyClusterRequirement, err := labels.NewRequirement(meta.SupabaseLabel.EnvoyCluster, selection.Exists, nil)
if err != nil {
return fmt.Errorf("preparing watcher selectors: %w", err)
}
selector.Add(*partOfRequirement, *nameRequirement, *envoyClusterRequirement)
watcher, err := c.Client.Watch(
ctx,
&endpointSlices,
client.MatchingLabelsSelector{
Selector: selector.Add(*partOfRequirement, *nameRequirement, *envoyClusterRequirement),
},
)
if err != nil {
return err
}
defer watcher.Stop()
for {
select {
case ev, more := <-watcher.ResultChan():
if !more {
return nil
}
eventLogger := logger.WithValues("event_type", ev.Type)
switch ev.Type {
case watch.Added, watch.Modified:
eps, ok := ev.Object.(*discoveryv1.EndpointSlice)
if !ok {
logger.Error(fmt.Errorf("%w: %T", ErrUnexpectedObject, ev.Object), "expected EndpointSlice but got a different object type")
continue
}
if err := c.handleModificationEvent(log.IntoContext(ctx, eventLogger), eps); err != nil {
logger.Error(err, "error occurred during event handling")
}
}
case <-ctx.Done():
return ctx.Err()
}
}
}
func (c *EndpointsController) handleModificationEvent(ctx context.Context, epSlice *discoveryv1.EndpointSlice) error {
c.lock.Lock()
defer c.lock.Unlock()
var (
logger = log.FromContext(ctx)
instanceKey string
svc *envoyClusterServices
)
logger.Info("Observed endpoint slice", "name", epSlice.Name)
if c.envoyClusters == nil {
c.envoyClusters = make(map[string]*envoyClusterServices)
}
envoyNodeName, ok := epSlice.Labels[meta.SupabaseLabel.EnvoyCluster]
if !ok {
return fmt.Errorf("%w: at object %s", ErrNoEnvoyClusterLabel, epSlice.Name)
}
instanceKey = fmt.Sprintf("%s:%s", envoyNodeName, epSlice.Namespace)
if svc, ok = c.envoyClusters[instanceKey]; !ok {
svc = new(envoyClusterServices)
}
svc.UpsertEndpoints(epSlice)
c.envoyClusters[instanceKey] = svc
return c.updateSnapshot(ctx, instanceKey)
}
func (c *EndpointsController) updateSnapshot(ctx context.Context, instance string) error {
latestVersion := strconv.FormatInt(time.Now().UTC().UnixMilli(), 10)
snapshot, err := c.envoyClusters[instance].snapshot(instance, latestVersion)
if err != nil {
return err
}
return c.Cache.SetSnapshot(ctx, instance, snapshot)
}
type envoyClusterServices struct {
Postgrest *PostgrestCluster
GoTrue *GoTrueCluster
PGMeta *PGMetaCluster
}
func (s *envoyClusterServices) UpsertEndpoints(eps *discoveryv1.EndpointSlice) {
switch eps.Labels[meta.WellKnownLabel.Name] {
case supabase.ServiceConfig.Postgrest.Name:
if s.Postgrest == nil {
s.Postgrest = new(PostgrestCluster)
}
s.Postgrest.AddOrUpdateEndpoints(eps)
case supabase.ServiceConfig.Auth.Name:
if s.GoTrue == nil {
s.GoTrue = new(GoTrueCluster)
}
s.GoTrue.AddOrUpdateEndpoints(eps)
case supabase.ServiceConfig.PGMeta.Name:
if s.PGMeta == nil {
s.PGMeta = new(PGMetaCluster)
}
s.PGMeta.AddOrUpdateEndpoints(eps)
}
}
func (s *envoyClusterServices) snapshot(instance, version string) (*cache.Snapshot, error) {
const (
routeName = "supabase"
vHostName = "supabase"
listenerName = "supabase"
)
manager := &hcm.HttpConnectionManager{
CodecType: hcm.HttpConnectionManager_AUTO,
StatPrefix: "http",
RouteSpecifier: &hcm.HttpConnectionManager_Rds{
Rds: &hcm.Rds{
ConfigSource: &corev3.ConfigSource{
ResourceApiVersion: resource.DefaultAPIVersion,
ConfigSourceSpecifier: &corev3.ConfigSource_ApiConfigSource{
ApiConfigSource: &corev3.ApiConfigSource{
TransportApiVersion: resource.DefaultAPIVersion,
ApiType: corev3.ApiConfigSource_GRPC,
SetNodeOnFirstMessageOnly: true,
GrpcServices: []*corev3.GrpcService{{
TargetSpecifier: &corev3.GrpcService_EnvoyGrpc_{
EnvoyGrpc: &corev3.GrpcService_EnvoyGrpc{ClusterName: "supabase-control-plane"},
},
}},
},
},
},
RouteConfigName: routeName,
},
},
HttpFilters: []*hcm.HttpFilter{
{
Name: FilterNameJwtAuthn,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(JWTFilterConfig())},
},
{
Name: FilterNameCORS,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(Cors())},
},
{
Name: FilterNameHttpRouter,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(new(router.Router))},
},
},
}
routeCfg := &route.RouteConfiguration{
Name: routeName,
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.PGMeta.Routes(instance),
),
}},
TypedPerFilterConfig: map[string]*anypb.Any{
FilterNameCORS: MustAny(CorsPolicy()),
},
}
listener := &listener.Listener{
Name: listenerName,
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: []*listener.FilterChain{
{
Filters: []*listener.Filter{
{
Name: FilterNameHttpConnectionManager,
ConfigType: &listener.Filter_TypedConfig{
TypedConfig: MustAny(manager),
},
},
},
},
},
}
rawSnapshot := map[resource.Type][]types.Resource{
resource.ClusterType: castResources(
slices.Concat(
s.Postgrest.Cluster(instance),
s.GoTrue.Cluster(instance),
s.PGMeta.Cluster(instance),
)...),
resource.RouteType: {routeCfg},
resource.ListenerType: {listener},
}
snapshot, err := cache.NewSnapshot(
version,
rawSnapshot,
)
if err != nil {
return nil, err
}
if err := snapshot.Consistent(); err != nil {
return nil, err
}
return snapshot, nil
}
func castResources[T types.Resource](from ...T) []types.Resource {
result := make([]types.Resource, len(from))
for idx := range from {
result[idx] = from[idx]
}
return result
}

View file

@ -0,0 +1,32 @@
package controlplane
import (
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
corsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/cors/v3"
matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
)
func Cors() *corsv3.Cors {
return new(corsv3.Cors)
}
func CorsPolicy() *corsv3.CorsPolicy {
return &corsv3.CorsPolicy{
AllowMethods: "*",
AllowHeaders: "*",
AllowOriginStringMatch: []*matcherv3.StringMatcher{{
MatchPattern: &matcherv3.StringMatcher_SafeRegex{
SafeRegex: &matcherv3.RegexMatcher{
Regex: `\*`,
},
},
}},
FilterEnabled: &corev3.RuntimeFractionalPercent{
DefaultValue: &typev3.FractionalPercent{
Numerator: 100,
Denominator: typev3.FractionalPercent_HUNDRED,
},
},
}
}

View file

@ -0,0 +1,90 @@
package controlplane
import (
"time"
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"
"google.golang.org/protobuf/types/known/durationpb"
discoveryv1 "k8s.io/api/discovery/v1"
)
type ServiceCluster struct {
ServiceEndpoints map[string]Endpoints
}
func (c *ServiceCluster) AddOrUpdateEndpoints(eps *discoveryv1.EndpointSlice) {
if c.ServiceEndpoints == nil {
c.ServiceEndpoints = make(map[string]Endpoints)
}
c.ServiceEndpoints[eps.Name] = newEndpointsFromSlice(eps)
}
func (c ServiceCluster) Cluster(name string, port uint32) *clusterv3.Cluster {
return &clusterv3.Cluster{
Name: name,
ConnectTimeout: durationpb.New(5 * time.Second),
ClusterDiscoveryType: &clusterv3.Cluster_Type{Type: clusterv3.Cluster_STATIC},
LbPolicy: clusterv3.Cluster_ROUND_ROBIN,
LoadAssignment: &endpointv3.ClusterLoadAssignment{
ClusterName: name,
Endpoints: c.endpoints(port),
},
}
}
func (c ServiceCluster) endpoints(port uint32) []*endpointv3.LocalityLbEndpoints {
eps := make([]*endpointv3.LocalityLbEndpoints, 0, len(c.ServiceEndpoints))
for _, sep := range c.ServiceEndpoints {
eps = append(eps, &endpointv3.LocalityLbEndpoints{
LbEndpoints: sep.LBEndpoints(port),
})
}
return eps
}
func newEndpointsFromSlice(eps *discoveryv1.EndpointSlice) Endpoints {
var result Endpoints
for _, ep := range eps.Endpoints {
if ep.Conditions.Ready != nil && *ep.Conditions.Ready {
result.Addresses = append(result.Addresses, ep.Addresses...)
}
}
return result
}
type Endpoints struct {
Addresses []string
}
func (e Endpoints) LBEndpoints(port uint32) []*endpointv3.LbEndpoint {
endpoints := make([]*endpointv3.LbEndpoint, 0, len(e.Addresses))
for _, ep := range e.Addresses {
endpoints = append(endpoints, &endpointv3.LbEndpoint{
HostIdentifier: &endpointv3.LbEndpoint_Endpoint{
Endpoint: &endpointv3.Endpoint{
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Address: ep,
Protocol: corev3.SocketAddress_TCP,
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: port,
},
},
},
},
},
},
})
}
return endpoints
}

View file

@ -0,0 +1,9 @@
package controlplane
const (
FilterNameJwtAuthn = "envoy.filters.http.jwt_authn"
FilterNameRBAC = "envoy.filters.http.rbac"
FilterNameCORS = "envoy.filters.http.cors"
FilterNameHttpRouter = "envoy.filters.http.router"
FilterNameHttpConnectionManager = "envoy.filters.network.http_connection_manager"
)

View file

@ -0,0 +1,76 @@
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"
matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
"google.golang.org/protobuf/types/known/anypb"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
type GoTrueCluster struct {
ServiceCluster
}
func (c *GoTrueCluster) Cluster(instance string) []*clusterv3.Cluster {
if c == nil {
return nil
}
return []*clusterv3.Cluster{c.ServiceCluster.Cluster(fmt.Sprintf("auth@%s", instance), 9999)}
}
func (c *GoTrueCluster) Routes(instance string) []*routev3.Route {
if c == nil {
return nil
}
return []*routev3.Route{
{
Name: "GoTrue (Open) /auth/v1/(callback|verify) -> http://auth:9999/$1",
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_SafeRegex{
SafeRegex: &matcherv3.RegexMatcher{
Regex: `/auth/v1/(callback|verify|authorize)`,
},
},
},
Action: &routev3.Route_Route{
Route: &routev3.RouteAction{
ClusterSpecifier: &routev3.RouteAction_Cluster{
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.Auth.Name, instance),
},
RegexRewrite: &matcherv3.RegexMatchAndSubstitute{
Pattern: &matcherv3.RegexMatcher{
Regex: `/auth/v1/(callback|verify|authorize)`,
},
Substitution: `/\1`,
},
},
},
TypedPerFilterConfig: map[string]*anypb.Any{
FilterNameRBAC: MustAny(RBACPerRoute(RBACAllowAllConfig())),
FilterNameJwtAuthn: MustAny(JWTAllowAll()),
},
},
{
Name: "GoTrue: /auth/v1/* -> http://auth:9999/*",
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_Prefix{
Prefix: "/auth/v1",
},
},
Action: &routev3.Route_Route{
Route: &routev3.RouteAction{
ClusterSpecifier: &routev3.RouteAction_Cluster{
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.Auth.Name, instance),
},
PrefixRewrite: "/",
},
},
},
}
}

View file

@ -0,0 +1,46 @@
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 PGMetaCluster struct {
ServiceCluster
}
func (c *PGMetaCluster) Cluster(instance string) []*clusterv3.Cluster {
if c == nil {
return nil
}
return []*clusterv3.Cluster{
c.ServiceCluster.Cluster(fmt.Sprintf("%s@%s", supabase.ServiceConfig.PGMeta.Name, instance), uint32(supabase.ServiceConfig.PGMeta.Defaults.APIPort)),
}
}
func (c *PGMetaCluster) Routes(instance string) []*routev3.Route {
if c == nil {
return nil
}
return []*routev3.Route{{
Name: "pg-meta: /pg/* -> http://pg-meta:8080/*",
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_Prefix{
Prefix: "/pg/",
},
},
Action: &routev3.Route_Route{
Route: &routev3.RouteAction{
ClusterSpecifier: &routev3.RouteAction_Cluster{
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.PGMeta.Name, instance),
},
PrefixRewrite: "/",
},
},
}}
}

View file

@ -0,0 +1,71 @@
package controlplane
import (
"fmt"
clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
type PostgrestCluster struct {
ServiceCluster
}
func (c *PostgrestCluster) Cluster(instance string) []*clusterv3.Cluster {
if c == nil {
return nil
}
return []*clusterv3.Cluster{
c.ServiceCluster.Cluster(fmt.Sprintf("%s@%s", supabase.ServiceConfig.Postgrest.Name, instance), 3000),
}
}
func (c *PostgrestCluster) Routes(instance string) []*routev3.Route {
if c == nil {
return nil
}
return []*routev3.Route{
{
Name: "PostgREST: /rest/v1/* -> http://rest:3000/*",
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_Prefix{
Prefix: "/rest/v1",
},
},
Action: &routev3.Route_Route{
Route: &routev3.RouteAction{
ClusterSpecifier: &routev3.RouteAction_Cluster{
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.Postgrest.Name, instance),
},
PrefixRewrite: "/",
},
},
},
{
Name: "PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql",
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_Prefix{
Prefix: "/graphql/v1",
},
},
Action: &routev3.Route_Route{
Route: &routev3.RouteAction{
ClusterSpecifier: &routev3.RouteAction_Cluster{
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.Postgrest.Name, instance),
},
PrefixRewrite: "/rpc/graphql",
},
},
RequestHeadersToAdd: []*corev3.HeaderValueOption{{
Header: &corev3.HeaderValue{
Key: "Content-Profile",
Value: "graphql_public",
},
}},
},
}
}

View file

@ -0,0 +1,15 @@
package controlplane
import (
proto "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)
func MustAny(msg proto.Message) *anypb.Any {
a, err := anypb.New(msg)
if err != nil {
panic(err)
}
return a
}

View file

@ -0,0 +1,69 @@
package db
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"sigs.k8s.io/controller-runtime/pkg/log"
"code.icb4dc0.de/prskr/supabase-operator/assets/migrations"
)
const (
alterUserPwd = `ALTER ROLE %s WITH PASSWORD '%s';`
checkUserExists = `SELECT 1 FROM pg_user WHERE usename = $1;`
)
func NewRolesManager(conn *pgx.Conn) RolesManager {
return RolesManager{
Conn: conn,
}
}
type RolesManager struct {
Conn *pgx.Conn
}
func (mgr RolesManager) UpdateRolePassword(ctx context.Context, roleName string, password []byte) error {
if err := mgr.ensureLoginRoleExists(ctx, roleName); err != nil {
return err
}
_, err := mgr.Conn.Exec(ctx, fmt.Sprintf(alterUserPwd, roleName, password))
return err
}
func (mgr RolesManager) ensureLoginRoleExists(ctx context.Context, roleName string) error {
logger := log.FromContext(ctx).WithValues("role_name", roleName)
rows, err := mgr.Conn.Query(ctx, checkUserExists, roleName)
if err != nil {
return err
}
defer rows.Close()
_, err = pgx.CollectExactlyOneRow(rows, func(row pgx.CollectableRow) (out int, err error) {
err = row.Scan(&out)
return
})
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
return err
}
logger.Info("No rows, this means the role does not exists, creating it now")
} else {
return nil
}
script, err := migrations.RoleCreationScript(roleName)
if err != nil {
return err
}
_, err = mgr.Conn.Exec(ctx, script.Content)
return err
}

48
internal/jwk/key.go Normal file
View file

@ -0,0 +1,48 @@
package jwk
import (
"encoding/base64"
"encoding/json"
)
type KeyType string
const (
KeyTypeEC KeyType = "EC"
KeyTypeRSA KeyType = "RSA"
KeyTypeOctetSequence KeyType = "oct"
)
type Algorithm string
const (
AlgorithmNone Algorithm = ""
AlgorithmHS256 Algorithm = "HS256"
AlgorithmHS384 Algorithm = "HS384"
AlgorithmHS512 Algorithm = "HS512"
)
var _ json.Marshaler = (*SymmetricKey)(nil)
type SymmetricKey struct {
Algorithm Algorithm
Key []byte
}
// MarshalJSON implements json.Marshaler.
func (s SymmetricKey) MarshalJSON() ([]byte, error) {
if s.Algorithm == AlgorithmNone {
s.Algorithm = AlgorithmHS256
}
tmp := struct {
KeyType KeyType `json:"kty"`
Algorithm Algorithm `json:"alg"`
Key string `json:"k"`
}{
KeyType: KeyTypeOctetSequence,
Algorithm: s.Algorithm,
Key: base64.RawURLEncoding.EncodeToString(s.Key),
}
return json.Marshal(tmp)
}

9
internal/jwk/set.go Normal file
View file

@ -0,0 +1,9 @@
package jwk
type Key interface {
SymmetricKey
}
type Set[T Key] struct {
Keys []T `json:"keys"`
}

47
internal/meta/labels.go Normal file
View file

@ -0,0 +1,47 @@
/*
Copyright 2024 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 meta
import supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
const (
WellKnownMetaPrefix = "app.kubernetes.io/"
)
var WellKnownLabel = struct {
Name string
Instance string
PartOf string
Version string
Component string
ManagedBy string
}{
Name: WellKnownMetaPrefix + "name",
Instance: WellKnownMetaPrefix + "instance",
PartOf: WellKnownMetaPrefix + "part-of",
Version: WellKnownMetaPrefix + "version",
Component: WellKnownMetaPrefix + "component",
ManagedBy: WellKnownMetaPrefix + "managed-by",
}
var SupabaseLabel = struct {
Reload string
EnvoyCluster string
}{
Reload: supabasev1alpha1.GroupVersion.Group + "/reload",
EnvoyCluster: supabasev1alpha1.GroupVersion.Group + "/envoy-cluster",
}

Some files were not shown because too many files have changed in this diff Show more