diff --git a/.dockerignore b/.dockerignore index a3aab7a..c1b2762 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file # Ignore build and test binaries. -bin/ +.idea +.devcontainer +.github +.zed +hack diff --git a/.gitignore b/.gitignore index e660fd9..9faa98f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ bin/ +out/ +.idea/ +.venv/ diff --git a/.golangci.yml b/.golangci.yml index 8f58401..1219d30 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -28,6 +28,7 @@ linters: - gofmt - goimports - gosimple + - godox - govet - ineffassign - lll diff --git a/Dockerfile b/Dockerfile index 4ba18b6..8c65e73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,33 +1,38 @@ # Build the manager binary -FROM golang:1.22 AS builder +FROM golang:1.23.4 AS builder ARG TARGETOS ARG TARGETARCH 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 # 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 cmd/main.go cmd/main.go -COPY api/ api/ -COPY internal/ internal/ +COPY [ "go.*", "./" ] +COPY [ "api", "api" ] +COPY [ "assets/migrations", "assets/migrations" ] +COPY [ "cmd", "cmd" ] +COPY [ "infrastructure", "infrastructure" ] +COPY [ "internal", "internal" ] +COPY [ "magefiles", "magefiles" ] +COPY [ "tools", "tools" ] # Build # 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 # 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. -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 # Refer to https://github.com/GoogleContainerTools/distroless for more details FROM gcr.io/distroless/static:nonroot WORKDIR / -COPY --from=builder /workspace/manager . +COPY --from=builder /workspace/supabase-operator . USER 65532:65532 -ENTRYPOINT ["/manager"] +ENTRYPOINT ["/supabase-operator"] diff --git a/PROJECT b/PROJECT index dd99649..3164000 100644 --- a/PROJECT +++ b/PROJECT @@ -17,4 +17,30 @@ resources: kind: Core 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: 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" diff --git a/Tiltfile b/Tiltfile new file mode 100644 index 0000000..eded6e4 --- /dev/null +++ b/Tiltfile @@ -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' + ], +) diff --git a/api/common.go b/api/common.go new file mode 100644 index 0000000..4cfaa18 --- /dev/null +++ b/api/common.go @@ -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] +} diff --git a/api/v1alpha1/apigateway_types.go b/api/v1alpha1/apigateway_types.go new file mode 100644 index 0000000..3d54f01 --- /dev/null +++ b/api/v1alpha1/apigateway_types.go @@ -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 + } + } + } +} diff --git a/api/v1alpha1/common_types.go b/api/v1alpha1/common_types.go new file mode 100644 index 0000000..b07c9bd --- /dev/null +++ b/api/v1alpha1/common_types.go @@ -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"` +} diff --git a/api/v1alpha1/core_types.go b/api/v1alpha1/core_types.go index 295fbe1..4061a68 100644 --- a/api/v1alpha1/core_types.go +++ b/api/v1alpha1/core_types.go @@ -19,46 +19,366 @@ package v1alpha1 import ( "context" "errors" + "fmt" + "iter" + "path" + "slices" + "strconv" + "strings" "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "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 { - DSN *string `json:"dsn,omitempty"` - DSNFrom *corev1.SecretKeySelector `json:"dsnFrom,omitempty"` + DSN *string `json:"dsn,omitempty"` + DSNSecretRef *corev1.SecretKeySelector `json:"dsnFrom,omitempty"` + Roles DatabaseRoles `json:"roles,omitempty"` } func (d Database) GetDSN(ctx context.Context, client client.Client) (string, error) { - if d.DSN != nil { - return *d.DSN, nil - } - - if d.DSNFrom == nil { + if d.DSNSecretRef == nil { return "", errors.New("DSN not set") } 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 } - data, ok := secret.Data[d.DSNFrom.Key] + data, ok := secret.Data[d.DSNSecretRef.Key] if !ok { - return "", errors.New("key not found in secret") + return "", fmt.Errorf("%w: %s", ErrNoSuchSecretValue, d.DSNSecretRef.Key) } 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 don’t 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. type CoreSpec struct { - // Important: Run "make" to regenerate code after modifying this file - - Database Database `json:"database,omitempty"` + JWT *JwtSpec `json:"jwt,omitempty"` + Database Database `json:"database,omitempty"` + Postgrest PostgrestSpec `json:"postgrest,omitempty"` + Auth *AuthSpec `json:"auth,omitempty"` } type MigrationStatus map[string]int64 @@ -72,11 +392,26 @@ func (s MigrationStatus) Record(name string) { 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. type CoreStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file - AppliedMigrations MigrationStatus `json:"appliedMigrations,omitempty"` + Database DatabaseStatus `json:"database,omitempty"` + Conditions []CoreCondition `json:"conditions,omitempty"` } // +kubebuilder:object:root=true @@ -100,6 +435,12 @@ type CoreList struct { Items []Core `json:"items"` } -func init() { - SchemeBuilder.Register(&Core{}, &CoreList{}) +func (l CoreList) Iter() iter.Seq[*Core] { + return func(yield func(*Core) bool) { + for _, c := range l.Items { + if !yield(&c) { + return + } + } + } } diff --git a/api/v1alpha1/dashboard_types.go b/api/v1alpha1/dashboard_types.go new file mode 100644 index 0000000..5e3b4b8 --- /dev/null +++ b/api/v1alpha1/dashboard_types.go @@ -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{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8725abc..7192356 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,15 +21,283 @@ limitations under the License. package v1alpha1 import ( + "k8s.io/api/core/v1" 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. func (in *Core) DeepCopyInto(out *Core) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -51,6 +319,23 @@ func (in *Core) DeepCopyObject() runtime.Object { 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. func (in *CoreList) DeepCopyInto(out *CoreList) { *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. func (in *CoreSpec) DeepCopyInto(out *CoreSpec) { *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. @@ -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. func (in *CoreStatus) DeepCopyInto(out *CoreStatus) { *out = *in - if in.AppliedMigrations != nil { - in, out := &in.AppliedMigrations, &out.AppliedMigrations - *out = make(map[string]uint64, len(*in)) - for key, val := range *in { - (*out)[key] = val + in.Database.DeepCopyInto(&out.Database) + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]CoreCondition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) } } } @@ -121,9 +418,144 @@ func (in *CoreStatus) DeepCopy() *CoreStatus { 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. func (in *Database) DeepCopyInto(out *Database) { *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. @@ -135,3 +567,399 @@ func (in *Database) DeepCopy() *Database { in.DeepCopyInto(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 +} diff --git a/assets/migrations/migrations.go b/assets/migrations/migrations.go index 0bcea5c..1c800fc 100644 --- a/assets/migrations/migrations.go +++ b/assets/migrations/migrations.go @@ -2,6 +2,7 @@ package migrations import ( "embed" + "fmt" "io/fs" "iter" "path" @@ -25,8 +26,18 @@ func MigrationScripts() iter.Seq2[Script, error] { 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] { - return iter.Seq2[Script, error](func(yield func(Script, error) bool) { + return func(yield func(Script, error) bool) { files, err := migrationsFS.ReadDir(dir) if err != nil { yield(Script{}, err) @@ -58,5 +69,5 @@ func readScripts(dir string) iter.Seq2[Script, error] { return } } - }) + } } diff --git a/assets/migrations/roles/supabase_functions_admin.sql b/assets/migrations/roles/supabase_functions_admin.sql new file mode 100644 index 0000000..ddf471b --- /dev/null +++ b/assets/migrations/roles/supabase_functions_admin.sql @@ -0,0 +1,5 @@ +create user supabase_functions_admin createrole noinherit; + +alter user supabase_functions_admin +set + search_path = supabase_functions; diff --git a/cmd/control_plane.go b/cmd/control_plane.go new file mode 100644 index 0000000..a00100b --- /dev/null +++ b/cmd/control_plane.go @@ -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 +} diff --git a/cmd/main.go b/cmd/main.go index 4b0f185..3c76731 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,26 +17,22 @@ limitations under the License. package main import ( - "crypto/tls" - "flag" + "context" "os" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // 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/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" 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/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" - "code.icb4dc0.de/prskr/supabase-operator/internal/controller" // +kubebuilder:scaffold:imports ) @@ -52,117 +48,45 @@ func init() { // +kubebuilder:scaffold:scheme } -func main() { - var metricsAddr string - var enableLeaderElection bool - var probeAddr string - var secureMetrics bool - var enableHTTP2 bool - var tlsOpts []func(*tls.Config) - flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ - "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") - 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. "+ - "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") +type app struct { + Manager manager `cmd:"" name:"manager" help:"Run the Kubernetes operator"` + ControlPlane controlPlane `cmd:"" name:"control-plane" help:"Run the Envoy control plane"` + + Logging struct { + Development bool `name:"development" default:"false"` + Level zapcore.Level `name:"level" default:"info"` + StacktraceLevel zapcore.Level `name:"stacktrace-level" default:"warn"` + } `embed:"" prefix:"logging."` +} + +func (a app) AfterApply(kongctx *kong.Context) error { opts := zap.Options{ - Development: true, - } - opts.BindFlags(flag.CommandLine) - flag.Parse() - - 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"} + Development: a.Logging.Development, + Level: a.Logging.Level, + StacktraceLevel: a.Logging.StacktraceLevel, + TimeEncoder: zapcore.ISO8601TimeEncoder, } - if !enableHTTP2 { - tlsOpts = append(tlsOpts, disableHTTP2) - } + logger := zap.New(zap.UseFlagOptions(&opts)) + ctrl.SetLogger(logger) + kongctx.Bind(logger) - webhookServer := webhook.NewServer(webhook.Options{ - TLSOpts: tlsOpts, - }) + logger.Info("Completed logger setup") - // 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: metricsAddr, - SecureServing: secureMetrics, - TLSOpts: tlsOpts, - } + return nil +} - if 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 +func main() { + var app app - // TODO(user): If CertDir, CertName, and KeyName are not specified, controller-runtime will automatically - // generate self-signed certificates for the metrics server. While convenient for development and testing, - // this setup is not recommended for production. - } + kongCtx := kong.Parse( + &app, + kong.Name("supabase-operator"), + kong.BindTo(ctrl.SetupSignalHandler(), (*context.Context)(nil)), + ) - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ - Scheme: scheme, - 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") + if err := kongCtx.Run(); err != nil { + setupLog.Error(err, "failed to run app") os.Exit(1) } } diff --git a/cmd/manager.go b/cmd/manager.go new file mode 100644 index 0000000..cbb52e3 --- /dev/null +++ b/cmd/manager.go @@ -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 +} diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml new file mode 100644 index 0000000..54ffce3 --- /dev/null +++ b/config/certmanager/certificate.yaml @@ -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 diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml new file mode 100644 index 0000000..bebea5a --- /dev/null +++ b/config/certmanager/kustomization.yaml @@ -0,0 +1,5 @@ +resources: +- certificate.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml new file mode 100644 index 0000000..cf6f89e --- /dev/null +++ b/config/certmanager/kustomizeconfig.yaml @@ -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 diff --git a/config/control-plane/control-plane.yaml b/config/control-plane/control-plane.yaml new file mode 100644 index 0000000..e594bb2 --- /dev/null +++ b/config/control-plane/control-plane.yaml @@ -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 diff --git a/config/control-plane/kustomization.yaml b/config/control-plane/kustomization.yaml new file mode 100644 index 0000000..0233292 --- /dev/null +++ b/config/control-plane/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - control-plane.yaml + - service.yaml diff --git a/config/control-plane/service.yaml b/config/control-plane/service.yaml new file mode 100644 index 0000000..b7c71d9 --- /dev/null +++ b/config/control-plane/service.yaml @@ -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 diff --git a/config/crd/bases/supabase.k8s.icb4dc0.de_apigateways.yaml b/config/crd/bases/supabase.k8s.icb4dc0.de_apigateways.yaml new file mode 100644 index 0000000..36709d4 --- /dev/null +++ b/config/crd/bases/supabase.k8s.icb4dc0.de_apigateways.yaml @@ -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['']`, `metadata.annotations['']`, + 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: {} diff --git a/config/crd/bases/supabase.k8s.icb4dc0.de_cores.yaml b/config/crd/bases/supabase.k8s.icb4dc0.de_cores.yaml index db446aa..918d21f 100644 --- a/config/crd/bases/supabase.k8s.icb4dc0.de_cores.yaml +++ b/config/crd/bases/supabase.k8s.icb4dc0.de_cores.yaml @@ -39,19 +39,1780 @@ spec: spec: description: CoreSpec defines the desired state of Core. properties: + auth: + properties: + additionalRedirectUrls: + items: + type: string + type: array + anonymousUsersEnabled: + type: boolean + disableSignup: + type: boolean + emailSignupDisabled: + type: boolean + externalUrl: + description: |- + APIExternalURL is referring to the URL where Supabase API will be available + Typically this is the ingress of the API gateway + type: string + providers: + properties: + azure: + properties: + clientID: + type: string + clientSecretRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + enabled: + description: Enabled - whether the authentication provider + is enabled or not + type: boolean + url: + type: string + required: + - clientID + - clientSecretRef + type: object + email: + properties: + adminEmail: + type: string + autoconfirmEmail: + type: boolean + enabled: + description: Enabled - whether the authentication provider + is enabled or not + type: boolean + senderName: + type: string + smtpSpec: + properties: + credentialsFrom: + 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 + host: + type: string + maxFrequency: + type: integer + port: + type: integer + required: + - credentialsFrom + - host + - port + type: object + subjectsConfirmation: + type: string + subjectsInvite: + type: string + required: + - adminEmail + - smtpSpec + type: object + github: + properties: + clientID: + type: string + clientSecretRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + enabled: + description: Enabled - whether the authentication provider + is enabled or not + type: boolean + url: + type: string + required: + - clientID + - clientSecretRef + type: object + phone: + properties: + enabled: + description: Enabled - whether the authentication provider + is enabled or not + type: boolean + type: object + type: object + siteUrl: + description: |- + 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 + type: string + workloadTemplate: + 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['']`, `metadata.annotations['']`, + 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: + - externalUrl + - siteUrl + type: object database: + properties: + dsn: + type: string + dsnFrom: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must be + a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key must be + defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + roles: + properties: + secrets: + description: Secrets - typed 'map' of secrets for each database + role that Supabase needs + properties: + authenticator: + 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 + supabaseAdmin: + 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 + supabaseAuthAdmin: + 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 + supabaseFunctionsAdmin: + 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 + supabaseStorageAdmin: + 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: object + selfManaged: + description: |- + 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 + type: boolean + type: object + type: object + jwt: + properties: + anonKey: + default: anon_key + description: AnonKey - key in secret where to read the anon JWT + from + type: string + expiry: + default: 3600 + description: Expiry - expiration time in seconds for JWTs + type: integer + jwksKey: + default: jwks.json + description: JwksKey - key in secret where to read the JWKS from + type: string + secret: + description: |- + Secret - JWT HMAC secret in plain text + This is WRITE-ONLY and will be copied to the SecretRef by the defaulter + type: string + secretKey: + default: secret + description: SecretKey - key in secret where to read the JWT HMAC + secret from + type: string + secretRef: + description: SecretRef - object reference to the Secret where + JWT values are stored + 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 + serviceKey: + default: service_key + description: ServiceKey - key in secret where to read the service + JWT from + type: string + type: object + postgrest: + properties: + anonRole: + default: anon + description: AnonRole - name of the anon role + type: string + extraSearchPath: + default: + - public + - extensions + description: |- + ExtraSearchPath - Extra schemas to add to the search_path of every request. + These schemas tables, views and functions don’t get API endpoints, they can only be referred from the database objects inside your db-schemas. + items: + type: string + type: array + maxRows: + default: 1000 + description: MaxRows - maximum number of rows PostgREST will load + at a time + type: integer + schemas: + default: + - public + - graphql_public + description: Schemas - schema where PostgREST is looking for objects + (tables, views, functions, ...) + items: + type: string + type: array + workloadTemplate: + description: WorkloadTemplate - customize the PostgREST workload + 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['']`, `metadata.annotations['']`, + 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 type: object type: object status: description: CoreStatus defines the observed state of Core. properties: - appliedMigrations: - additionalProperties: - format: int64 - type: integer - description: |- - INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - Important: Run "make" to regenerate code after modifying this file + conditions: + items: + properties: + lastProbeTime: + format: date-time + type: string + lastTransitionTime: + format: date-time + type: string + message: + type: string + reason: + type: string + status: + type: string + type: + type: string + required: + - status + - type + type: object + type: array + database: + properties: + appliedMigrations: + additionalProperties: + format: int64 + type: integer + type: object + roles: + additionalProperties: + format: byte + type: string + type: object type: object type: object type: object diff --git a/config/crd/bases/supabase.k8s.icb4dc0.de_dashboards.yaml b/config/crd/bases/supabase.k8s.icb4dc0.de_dashboards.yaml new file mode 100644 index 0000000..7610b19 --- /dev/null +++ b/config/crd/bases/supabase.k8s.icb4dc0.de_dashboards.yaml @@ -0,0 +1,1469 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.5 + name: dashboards.supabase.k8s.icb4dc0.de +spec: + group: supabase.k8s.icb4dc0.de + names: + kind: Dashboard + listKind: DashboardList + plural: dashboards + singular: dashboard + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Dashboard is the Schema for the dashboards 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: DashboardSpec defines the desired state of Dashboard. + properties: + db: + properties: + dbCredentialsRef: + description: |- + DBCredentialsRef - reference to a Secret key where the DB credentials can be retrieved from + Credentials need to be stored in basic auth form + 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 + dbName: + type: string + host: + type: string + port: + default: 5432 + description: Port - Database port, typically 5432 + type: integer + required: + - dbCredentialsRef + - dbName + - host + type: object + pgMeta: + default: {} + description: PGMeta + properties: + workloadTemplate: + description: WorkloadTemplate - customize the pg-meta 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['']`, `metadata.annotations['']`, + 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 + type: object + studio: + default: {} + description: Studio + properties: + workloadTemplate: + description: WorkloadTemplate - customize the studio 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['']`, `metadata.annotations['']`, + 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 + type: object + required: + - db + type: object + status: + description: DashboardStatus defines the observed state of Dashboard. + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 8b99f5d..cc2769b 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,8 +2,10 @@ # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default resources: -- bases/supabase.k8s.icb4dc0.de_cores.yaml -# +kubebuilder:scaffold:crdkustomizeresource + - bases/supabase.k8s.icb4dc0.de_cores.yaml + - bases/supabase.k8s.icb4dc0.de_apigateways.yaml + - bases/supabase.k8s.icb4dc0.de_dashboards.yaml + # +kubebuilder:scaffold:crdkustomizeresource patches: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. @@ -16,5 +18,5 @@ patches: # [WEBHOOK] To enable webhook, uncomment the following section # the following config is for teaching kustomize how to do kustomization for CRDs. -#configurations: -#- kustomizeconfig.yaml +configurations: + - kustomizeconfig.yaml diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 82f79fd..e48da28 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -1,12 +1,12 @@ # Adds namespace to all resources. -namespace: supabase-operator-system +namespace: supabase-system # Value of this field is prepended to the # names of all resources, e.g. a deployment named # "wordpress" becomes "alices-wordpress". # Note that it should also match with the prefix (text before '-') of the namespace # field above. -namePrefix: supabase-operator- +namePrefix: supabase- # Labels to add to all resources and selectors. #labels: @@ -15,18 +15,16 @@ namePrefix: supabase-operator- # someName: someValue resources: -- ../crd -- ../rbac -- ../manager -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -#- ../webhook -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -#- ../certmanager -# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. -#- ../prometheus -# [METRICS] Expose the controller manager metrics service. -- metrics_service.yaml + - ../crd + - ../rbac + - ../manager + - ../control-plane + - ../webhook + - ../certmanager + # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. + #- ../prometheus + # [METRICS] Expose the controller manager metrics service. + - metrics_service.yaml # [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. # Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics. # 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 patches: -# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. -# More info: https://book.kubebuilder.io/reference/metrics -- path: manager_metrics_patch.yaml - target: - kind: Deployment + # [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. + # More info: https://book.kubebuilder.io/reference/metrics + - path: manager_metrics_patch.yaml + target: + kind: Deployment + name: controller-manager -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -#- path: manager_webhook_patch.yaml + # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in + # crd/kustomization.yaml + - path: manager_webhook_patch.yaml # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. # Uncomment the following replacements to add the cert-manager CA injection annotations -#replacements: -# - source: # Uncomment the following block if you have any webhook -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.name # Name of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 0 -# create: true -# - source: -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.namespace # Namespace of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 1 -# create: true -# -# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # This name should match the one in certificate.yaml -# fieldPath: .metadata.namespace # Namespace of the certificate CR -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - source: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # This name should match the one in certificate.yaml -# fieldPath: .metadata.name -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# -# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # This name should match the one in certificate.yaml -# fieldPath: .metadata.namespace # Namespace of the certificate CR -# targets: -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - source: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # This name should match the one in certificate.yaml -# fieldPath: .metadata.name -# targets: -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# +replacements: + - source: # Uncomment the following block if you have any webhook + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.name # Name of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: "." + index: 0 + create: true + - source: + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.namespace # Namespace of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: "." + index: 1 + create: true + + - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # This name should match the one in certificate.yaml + fieldPath: .metadata.namespace # Namespace of the certificate CR + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: "/" + index: 0 + create: true + - source: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # This name should match the one in certificate.yaml + fieldPath: .metadata.name + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: "/" + index: 1 + create: true + + - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # This name should match the one in certificate.yaml + fieldPath: .metadata.namespace # Namespace of the certificate CR + targets: + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: "/" + index: 0 + create: true + - source: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # This name should match the one in certificate.yaml + fieldPath: .metadata.name + targets: + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: "/" + index: 1 + create: true # - source: # Uncomment the following block if you have a ConversionWebhook (--conversion) # kind: Certificate # group: cert-manager.io diff --git a/config/default/manager_metrics_patch.yaml b/config/default/manager_metrics_patch.yaml index 2aaef65..86e888d 100644 --- a/config/default/manager_metrics_patch.yaml +++ b/config/default/manager_metrics_patch.yaml @@ -1,4 +1,4 @@ # This patch adds the args to allow exposing the metrics endpoint using HTTPS - op: add - path: /spec/template/spec/containers/0/args/0 + path: /spec/template/spec/containers/0/args/1 value: --metrics-bind-address=:8443 diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml new file mode 100644 index 0000000..167c6af --- /dev/null +++ b/config/default/manager_webhook_patch.yaml @@ -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 diff --git a/config/default/metrics_service.yaml b/config/default/metrics_service.yaml index 9264fb3..a15d7bc 100644 --- a/config/default/metrics_service.yaml +++ b/config/default/metrics_service.yaml @@ -6,12 +6,12 @@ metadata: app.kubernetes.io/name: supabase-operator app.kubernetes.io/managed-by: kustomize name: controller-manager-metrics-service - namespace: system + namespace: supabase-system spec: ports: - - name: https - port: 8443 - protocol: TCP - targetPort: 8443 + - name: https + port: 8443 + protocol: TCP + targetPort: 8443 selector: control-plane: controller-manager diff --git a/config/dev/kustomization.yaml b/config/dev/kustomization.yaml new file mode 100644 index 0000000..958a5e5 --- /dev/null +++ b/config/dev/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../default + +patches: + - path: manager_dev_settings.yaml + target: + kind: Deployment diff --git a/config/dev/manager_dev_settings.yaml b/config/dev/manager_dev_settings.yaml new file mode 100644 index 0000000..e2856a3 --- /dev/null +++ b/config/dev/manager_dev_settings.yaml @@ -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 diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 1166b21..db66ae7 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -5,13 +5,13 @@ metadata: control-plane: controller-manager app.kubernetes.io/name: supabase-operator app.kubernetes.io/managed-by: kustomize - name: system + name: supabase-system --- apiVersion: apps/v1 kind: Deployment metadata: name: controller-manager - namespace: system + namespace: supabase-system labels: control-plane: controller-manager app.kubernetes.io/name: supabase-operator @@ -50,46 +50,45 @@ spec: # - linux securityContext: runAsNonRoot: true - # TODO(user): For common cases that do not require escalating privileges - # it is recommended to ensure that all your Pods/Containers are restrictive. - # More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted - # Please uncomment the following code if your project does NOT have to work on old Kubernetes - # versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ). - # seccompProfile: - # type: RuntimeDefault + seccompProfile: + type: RuntimeDefault containers: - - command: - - /manager - args: - - --leader-elect - - --health-probe-bind-address=:8081 - image: controller:latest - name: manager - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - "ALL" - livenessProbe: - httpGet: - path: /healthz - port: 8081 - initialDelaySeconds: 15 - periodSeconds: 20 - readinessProbe: - httpGet: - path: /readyz - port: 8081 - initialDelaySeconds: 5 - periodSeconds: 10 - # TODO(user): Configure the resources accordingly based on the project requirements. - # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - resources: - limits: - cpu: 500m - memory: 128Mi - requests: - cpu: 10m - memory: 64Mi + - args: + - manager + - --leader-elect + - --health-probe-bind-address=:8081 + image: supabase-operator:latest + name: manager + env: + - name: CONTROLLER_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + # TODO(user): Configure the resources accordingly based on the project requirements. + # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi serviceAccountName: controller-manager terminationGracePeriodSeconds: 10 diff --git a/config/network-policy/allow-metrics-traffic.yaml b/config/network-policy/allow-metrics-traffic.yaml index 482567a..c1af1bf 100644 --- a/config/network-policy/allow-metrics-traffic.yaml +++ b/config/network-policy/allow-metrics-traffic.yaml @@ -8,7 +8,7 @@ metadata: app.kubernetes.io/name: supabase-operator app.kubernetes.io/managed-by: kustomize name: allow-metrics-traffic - namespace: system + namespace: supabase-system spec: podSelector: matchLabels: @@ -18,9 +18,9 @@ spec: ingress: # This allows ingress traffic from any namespace with the label metrics: enabled - from: - - namespaceSelector: - matchLabels: - metrics: enabled # Only from namespaces with this label + - namespaceSelector: + matchLabels: + metrics: enabled # Only from namespaces with this label ports: - port: 8443 protocol: TCP diff --git a/config/network-policy/allow-webhook-traffic.yaml b/config/network-policy/allow-webhook-traffic.yaml new file mode 100644 index 0000000..c7305be --- /dev/null +++ b/config/network-policy/allow-webhook-traffic.yaml @@ -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 diff --git a/config/network-policy/kustomization.yaml b/config/network-policy/kustomization.yaml index ec0fb5e..0872bee 100644 --- a/config/network-policy/kustomization.yaml +++ b/config/network-policy/kustomization.yaml @@ -1,2 +1,3 @@ resources: +- allow-webhook-traffic.yaml - allow-metrics-traffic.yaml diff --git a/config/prometheus/monitor.yaml b/config/prometheus/monitor.yaml index 898f48e..6372ad7 100644 --- a/config/prometheus/monitor.yaml +++ b/config/prometheus/monitor.yaml @@ -7,7 +7,7 @@ metadata: app.kubernetes.io/name: supabase-operator app.kubernetes.io/managed-by: kustomize name: controller-manager-metrics-monitor - namespace: system + namespace: supabase-system spec: endpoints: - path: /metrics diff --git a/config/rbac/apigateway_editor_role.yaml b/config/rbac/apigateway_editor_role.yaml new file mode 100644 index 0000000..9af8a57 --- /dev/null +++ b/config/rbac/apigateway_editor_role.yaml @@ -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 diff --git a/config/rbac/apigateway_viewer_role.yaml b/config/rbac/apigateway_viewer_role.yaml new file mode 100644 index 0000000..58e067a --- /dev/null +++ b/config/rbac/apigateway_viewer_role.yaml @@ -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 diff --git a/config/rbac/control-plane-role.yaml b/config/rbac/control-plane-role.yaml new file mode 100644 index 0000000..e85f47b --- /dev/null +++ b/config/rbac/control-plane-role.yaml @@ -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 diff --git a/config/rbac/control-plane-role_binding.yaml b/config/rbac/control-plane-role_binding.yaml new file mode 100644 index 0000000..f19a162 --- /dev/null +++ b/config/rbac/control-plane-role_binding.yaml @@ -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 diff --git a/config/rbac/control-plane-service_account.yaml b/config/rbac/control-plane-service_account.yaml new file mode 100644 index 0000000..5d58d02 --- /dev/null +++ b/config/rbac/control-plane-service_account.yaml @@ -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 diff --git a/config/rbac/dashboard_editor_role.yaml b/config/rbac/dashboard_editor_role.yaml new file mode 100644 index 0000000..adb4416 --- /dev/null +++ b/config/rbac/dashboard_editor_role.yaml @@ -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 diff --git a/config/rbac/dashboard_viewer_role.yaml b/config/rbac/dashboard_viewer_role.yaml new file mode 100644 index 0000000..cc2d49d --- /dev/null +++ b/config/rbac/dashboard_viewer_role.yaml @@ -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 diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 306e950..37a83d4 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -1,27 +1,38 @@ resources: -# All RBAC will be applied under this service account in -# the deployment namespace. You may comment out this resource -# if your manager will use a service account that exists at -# runtime. Be sure to update RoleBinding and ClusterRoleBinding -# subjects if changing service account names. -- service_account.yaml -- role.yaml -- role_binding.yaml -- leader_election_role.yaml -- leader_election_role_binding.yaml -# The following RBAC configurations are used to protect -# the metrics endpoint with authn/authz. These configurations -# ensure that only authorized users and service accounts -# can access the metrics endpoint. Comment the following -# permissions if you want to disable this protection. -# More info: https://book.kubebuilder.io/reference/metrics.html -- metrics_auth_role.yaml -- metrics_auth_role_binding.yaml -- metrics_reader_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. -- core_editor_role.yaml -- core_viewer_role.yaml - + # All RBAC will be applied under this service account in + # the deployment namespace. You may comment out this resource + # if your manager will use a service account that exists at + # runtime. Be sure to update RoleBinding and ClusterRoleBinding + # subjects if changing service account names. + - service_account.yaml + - role.yaml + - role_binding.yaml + - leader_election_role.yaml + - leader_election_role_binding.yaml + # RBAC role for the control plane + - control-plane-service_account.yaml + - control-plane-role.yaml + - control-plane-role_binding.yaml + # The following RBAC configurations are used to protect + # the metrics endpoint with authn/authz. These configurations + # ensure that only authorized users and service accounts + # can access the metrics endpoint. Comment the following + # permissions if you want to disable this protection. + # More info: https://book.kubebuilder.io/reference/metrics.html + - metrics_auth_role.yaml + - metrics_auth_role_binding.yaml + - metrics_reader_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. + - 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 diff --git a/config/rbac/leader_election_role_binding.yaml b/config/rbac/leader_election_role_binding.yaml index 609ab22..60907a6 100644 --- a/config/rbac/leader_election_role_binding.yaml +++ b/config/rbac/leader_election_role_binding.yaml @@ -10,6 +10,6 @@ roleRef: kind: Role name: leader-election-role subjects: -- kind: ServiceAccount - name: controller-manager - namespace: system + - kind: ServiceAccount + name: controller-manager + namespace: supabase-system diff --git a/config/rbac/metrics_auth_role_binding.yaml b/config/rbac/metrics_auth_role_binding.yaml index e775d67..cc7d623 100644 --- a/config/rbac/metrics_auth_role_binding.yaml +++ b/config/rbac/metrics_auth_role_binding.yaml @@ -7,6 +7,6 @@ roleRef: kind: ClusterRole name: metrics-auth-role subjects: -- kind: ServiceAccount - name: controller-manager - namespace: system + - kind: ServiceAccount + name: controller-manager + namespace: supabase-system diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 02ba059..a47705e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -5,9 +5,29 @@ metadata: name: manager-role rules: - apiGroups: - - supabase.k8s.icb4dc0.de + - "" resources: - - cores + - configmaps + - secrets + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create +- apiGroups: + - apps + resources: + - deployments verbs: - create - delete @@ -19,13 +39,31 @@ rules: - apiGroups: - supabase.k8s.icb4dc0.de resources: + - apigateways + - cores + - dashboards + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - supabase.k8s.icb4dc0.de + resources: + - apigateways/finalizers - cores/finalizers + - dashboards/finalizers verbs: - update - apiGroups: - supabase.k8s.icb4dc0.de resources: + - apigateways/status - cores/status + - dashboards/status verbs: - get - patch diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml index 520447b..4745b4c 100644 --- a/config/rbac/role_binding.yaml +++ b/config/rbac/role_binding.yaml @@ -10,6 +10,6 @@ roleRef: kind: ClusterRole name: manager-role subjects: -- kind: ServiceAccount - name: controller-manager - namespace: system + - kind: ServiceAccount + name: controller-manager + namespace: supabase-system diff --git a/config/rbac/service_account.yaml b/config/rbac/service_account.yaml index d38c1fa..8ad2bce 100644 --- a/config/rbac/service_account.yaml +++ b/config/rbac/service_account.yaml @@ -5,4 +5,4 @@ metadata: app.kubernetes.io/name: supabase-operator app.kubernetes.io/managed-by: kustomize name: controller-manager - namespace: system + namespace: supabase-system diff --git a/examples/db/cluster.yaml b/config/samples/cnpg-cluster.yaml similarity index 97% rename from examples/db/cluster.yaml rename to config/samples/cnpg-cluster.yaml index 7e3cac5..74aa01d 100644 --- a/examples/db/cluster.yaml +++ b/config/samples/cnpg-cluster.yaml @@ -40,7 +40,7 @@ metadata: name: cluster-example spec: instances: 1 - imageName: ghcr.io/supabase/postgres:15.6.1.145 + imageName: ghcr.io/supabase/postgres:15.8.1.021 postgresUID: 105 postgresGID: 106 diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index ef9ad1f..e32f0fd 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,4 +1,11 @@ ## Append samples of your project ## + +namespace: supabase-demo + resources: -- supabase_v1alpha1_core.yaml -# +kubebuilder:scaffold:manifestskustomizesamples + - namespace.yaml + - cnpg-cluster.yaml + - supabase_v1alpha1_core.yaml + - supabase_v1alpha1_apigateway.yaml + - supabase_v1alpha1_dashboard.yaml + # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/namespace.yaml b/config/samples/namespace.yaml new file mode 100644 index 0000000..c8d3ce3 --- /dev/null +++ b/config/samples/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: supabase-demo diff --git a/config/samples/supabase_v1alpha1_apigateway.yaml b/config/samples/supabase_v1alpha1_apigateway.yaml new file mode 100644 index 0000000..de489e5 --- /dev/null +++ b/config/samples/supabase_v1alpha1_apigateway.yaml @@ -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 diff --git a/config/samples/supabase_v1alpha1_core.yaml b/config/samples/supabase_v1alpha1_core.yaml index 1f54be9..4063bfd 100644 --- a/config/samples/supabase_v1alpha1_core.yaml +++ b/config/samples/supabase_v1alpha1_core.yaml @@ -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 kind: Core metadata: @@ -8,5 +15,11 @@ metadata: spec: database: dsnFrom: - name: example-cluster-credentials + name: supabase-demo-credentials key: url + auth: + externalUrl: http://localhost:8000/ + siteUrl: http://localhost:3000/ + disableSignup: true + enableEmailAutoconfirm: true + providers: {} diff --git a/config/samples/supabase_v1alpha1_dashboard.yaml b/config/samples/supabase_v1alpha1_dashboard.yaml new file mode 100644 index 0000000..fa128d3 --- /dev/null +++ b/config/samples/supabase_v1alpha1_dashboard.yaml @@ -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 diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml new file mode 100644 index 0000000..9cf2613 --- /dev/null +++ b/config/webhook/kustomization.yaml @@ -0,0 +1,6 @@ +resources: +- manifests.yaml +- service.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml new file mode 100644 index 0000000..206316e --- /dev/null +++ b/config/webhook/kustomizeconfig.yaml @@ -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 diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml new file mode 100644 index 0000000..dc1b14b --- /dev/null +++ b/config/webhook/manifests.yaml @@ -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 diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml new file mode 100644 index 0000000..3b6d809 --- /dev/null +++ b/config/webhook/service.yaml @@ -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 diff --git a/crd-docs.yaml b/crd-docs.yaml new file mode 100644 index 0000000..71c2cdd --- /dev/null +++ b/crd-docs.yaml @@ -0,0 +1,9 @@ +processor: + ignoreTypes: [] + ignoreFields: + - "status$" + - "TypeMeta$" + +render: + kubernetesVersion: "1.30" + knownTypes: [] diff --git a/dev/Dockerfile b/dev/Dockerfile new file mode 100644 index 0000000..62adf4f --- /dev/null +++ b/dev/Dockerfile @@ -0,0 +1,5 @@ +FROM alpine:3.21 +WORKDIR /app +ADD --chown=65532:65532 ./out ./bin +USER 65532:65532 +ENTRYPOINT [ "/app/bin/supabase-operator" ] diff --git a/dev/cluster.yaml b/dev/cluster.yaml new file mode 100644 index 0000000..30d89f7 --- /dev/null +++ b/dev/cluster.yaml @@ -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 diff --git a/dev/prepare-dev-cluster.sh b/dev/prepare-dev-cluster.sh new file mode 100755 index 0000000..a4a51bb --- /dev/null +++ b/dev/prepare-dev-cluster.sh @@ -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 diff --git a/docs/api/supabase.k8s.icb4dc0.de.md b/docs/api/supabase.k8s.icb4dc0.de.md new file mode 100644 index 0000000..16d1906 --- /dev/null +++ b/docs/api/supabase.k8s.icb4dc0.de.md @@ -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
Typically this is the ingress of the API gateway | | | +| `siteUrl` _string_ | 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 | | | +| `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
| + + +#### 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
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 | | | +| `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
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
| +| `extraSearchPath` _string array_ | ExtraSearchPath - Extra schemas to add to the search_path of every request.
These schemas tables, views and functions don’t get API endpoints, they can only be referred from the database objects inside your db-schemas. | [public extensions] | UniqueItems: true
| +| `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)_ | | | | + + diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..000ea34 --- /dev/null +++ b/docs/index.md @@ -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. diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 0000000..11dda91 --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} {% block extrahead %} + +{{ super() }} + + + + +{% endblock %} diff --git a/go.mod b/go.mod index 2097aca..10b7ee2 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,16 @@ module code.icb4dc0.de/prskr/supabase-operator go 1.23.4 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/lestrrat-go/jwx/v2 v2.1.3 github.com/magefile/mage v1.15.0 github.com/onsi/ginkgo/v2 v2.19.0 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 k8s.io/api v0.31.0 k8s.io/apimachinery v0.31.0 @@ -16,14 +21,19 @@ require ( ) require ( + cel.dev/expr v0.15.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.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/cncf/xds/go v0.0.0-20240423153145-555b57ec207b // 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/envoyproxy/protoc-gen-validate v1.1.0 // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/felixge/httpsnoop v1.0.4 // 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/swag v0.22.4 // 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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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/josharian/intern v1.0.0 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // 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/pflag v1.0.5 // 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/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.26.0 // indirect - golang.org/x/crypto v0.27.0 // indirect + golang.org/x/crypto v0.29.0 // indirect + golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/term v0.24.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/term v0.26.0 // indirect + golang.org/x/text v0.20.0 // indirect golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // 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/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/yaml.v2 v2.4.0 // indirect k8s.io/apiextensions-apiserver v0.31.0 // indirect diff --git a/go.sum b/go.sum index 11676ef..a459188 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= 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/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/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/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/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.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/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/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/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= 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-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/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/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +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/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 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/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 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/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 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/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.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.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +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/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 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-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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +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-20190412213103-97732733099d/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.25.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.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= +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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +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/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/controller/apigateway_controller.go b/internal/controller/apigateway_controller.go new file mode 100644 index 0000000..46837fe --- /dev/null +++ b/internal/controller/apigateway_controller.go @@ -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 +} diff --git a/internal/controller/apigateway_controller_test.go b/internal/controller/apigateway_controller_test.go new file mode 100644 index 0000000..f92a783 --- /dev/null +++ b/internal/controller/apigateway_controller_test.go @@ -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. + }) + }) +}) diff --git a/internal/controller/core_controller.go b/internal/controller/core_controller.go deleted file mode 100644 index c54f8d1..0000000 --- a/internal/controller/core_controller.go +++ /dev/null @@ -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)) -} diff --git a/internal/controller/core_db_controller.go b/internal/controller/core_db_controller.go new file mode 100644 index 0000000..f5ea369 --- /dev/null +++ b/internal/controller/core_db_controller.go @@ -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 +} diff --git a/internal/controller/core_controller_test.go b/internal/controller/core_db_controller_test.go similarity index 98% rename from internal/controller/core_controller_test.go rename to internal/controller/core_db_controller_test.go index 6388d2f..9a2aa5c 100644 --- a/internal/controller/core_controller_test.go +++ b/internal/controller/core_db_controller_test.go @@ -68,7 +68,7 @@ var _ = Describe("Core Controller", func() { }) It("should successfully reconcile the resource", func() { By("Reconciling the created resource") - controllerReconciler := &CoreReconciler{ + controllerReconciler := &CoreDbReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), } diff --git a/internal/controller/core_gotrue_controller.go b/internal/controller/core_gotrue_controller.go new file mode 100644 index 0000000..227e7a7 --- /dev/null +++ b/internal/controller/core_gotrue_controller.go @@ -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 +} diff --git a/internal/controller/core_jwt_controller.go b/internal/controller/core_jwt_controller.go new file mode 100644 index 0000000..d0db24e --- /dev/null +++ b/internal/controller/core_jwt_controller.go @@ -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)) +} diff --git a/internal/controller/core_postgrest_controller.go b/internal/controller/core_postgrest_controller.go new file mode 100644 index 0000000..43326ae --- /dev/null +++ b/internal/controller/core_postgrest_controller.go @@ -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 +} diff --git a/internal/controller/dashboard_controller_test.go b/internal/controller/dashboard_controller_test.go new file mode 100644 index 0000000..885d91b --- /dev/null +++ b/internal/controller/dashboard_controller_test.go @@ -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. + }) + }) +}) diff --git a/internal/controller/dashboard_pg-meta_controller.go b/internal/controller/dashboard_pg-meta_controller.go new file mode 100644 index 0000000..5b7662a --- /dev/null +++ b/internal/controller/dashboard_pg-meta_controller.go @@ -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 +} diff --git a/internal/controller/object_meta.go b/internal/controller/object_meta.go new file mode 100644 index 0000000..dfb805e --- /dev/null +++ b/internal/controller/object_meta.go @@ -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 +} diff --git a/internal/controller/permissions.go b/internal/controller/permissions.go new file mode 100644 index 0000000..a489023 --- /dev/null +++ b/internal/controller/permissions.go @@ -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 diff --git a/internal/controller/pwgen.go b/internal/controller/pwgen.go new file mode 100644 index 0000000..6e0cbde --- /dev/null +++ b/internal/controller/pwgen.go @@ -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) +} diff --git a/internal/controller/templates/envoy_control_plane_config.yaml.tmpl b/internal/controller/templates/envoy_control_plane_config.yaml.tmpl new file mode 100644 index 0000000..596dc8d --- /dev/null +++ b/internal/controller/templates/envoy_control_plane_config.yaml.tmpl @@ -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 diff --git a/internal/controller/utils.go b/internal/controller/utils.go new file mode 100644 index 0000000..529042e --- /dev/null +++ b/internal/controller/utils.go @@ -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 + }) +} diff --git a/internal/controlplane/auth_filters.go b/internal/controlplane/auth_filters.go new file mode 100644 index 0000000..31a4995 --- /dev/null +++ b/internal/controlplane/auth_filters.go @@ -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", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }}, + }, + }, + }, + } +} diff --git a/internal/controlplane/controller.go b/internal/controlplane/controller.go new file mode 100644 index 0000000..4c139e0 --- /dev/null +++ b/internal/controlplane/controller.go @@ -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 +} diff --git a/internal/controlplane/cors.go b/internal/controlplane/cors.go new file mode 100644 index 0000000..447e2b1 --- /dev/null +++ b/internal/controlplane/cors.go @@ -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, + }, + }, + } +} diff --git a/internal/controlplane/endpoints.go b/internal/controlplane/endpoints.go new file mode 100644 index 0000000..5890c4e --- /dev/null +++ b/internal/controlplane/endpoints.go @@ -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 +} diff --git a/internal/controlplane/filters.go b/internal/controlplane/filters.go new file mode 100644 index 0000000..2e58761 --- /dev/null +++ b/internal/controlplane/filters.go @@ -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" +) diff --git a/internal/controlplane/gotrue.go b/internal/controlplane/gotrue.go new file mode 100644 index 0000000..00dd1a7 --- /dev/null +++ b/internal/controlplane/gotrue.go @@ -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: "/", + }, + }, + }, + } +} diff --git a/internal/controlplane/pg-meta.go b/internal/controlplane/pg-meta.go new file mode 100644 index 0000000..1b5b857 --- /dev/null +++ b/internal/controlplane/pg-meta.go @@ -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: "/", + }, + }, + }} +} diff --git a/internal/controlplane/postgrest.go b/internal/controlplane/postgrest.go new file mode 100644 index 0000000..5fc4892 --- /dev/null +++ b/internal/controlplane/postgrest.go @@ -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", + }, + }}, + }, + } +} diff --git a/internal/controlplane/utils.go b/internal/controlplane/utils.go new file mode 100644 index 0000000..eca6f79 --- /dev/null +++ b/internal/controlplane/utils.go @@ -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 +} diff --git a/infrastructure/db/migrator.go b/internal/db/migrator.go similarity index 100% rename from infrastructure/db/migrator.go rename to internal/db/migrator.go diff --git a/internal/db/roles_manager.go b/internal/db/roles_manager.go new file mode 100644 index 0000000..19ede6c --- /dev/null +++ b/internal/db/roles_manager.go @@ -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 +} diff --git a/internal/jwk/key.go b/internal/jwk/key.go new file mode 100644 index 0000000..c02efbc --- /dev/null +++ b/internal/jwk/key.go @@ -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) +} diff --git a/internal/jwk/set.go b/internal/jwk/set.go new file mode 100644 index 0000000..4b7d971 --- /dev/null +++ b/internal/jwk/set.go @@ -0,0 +1,9 @@ +package jwk + +type Key interface { + SymmetricKey +} + +type Set[T Key] struct { + Keys []T `json:"keys"` +} diff --git a/internal/meta/labels.go b/internal/meta/labels.go new file mode 100644 index 0000000..b17d753 --- /dev/null +++ b/internal/meta/labels.go @@ -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", +} diff --git a/internal/supabase/db_roles.go b/internal/supabase/db_roles.go new file mode 100644 index 0000000..f0ccd60 --- /dev/null +++ b/internal/supabase/db_roles.go @@ -0,0 +1,26 @@ +package supabase + +import "strings" + +type DBRole string + +func (r DBRole) String() string { + return string(r) +} + +func (r DBRole) K8sString() string { + return strings.ReplaceAll(r.String(), "_", "-") +} + +func (r DBRole) Bytes() []byte { + s := string(r) + return []byte(s) +} + +const ( + DBRoleAuthenticator DBRole = "authenticator" + DBRoleAuthAdmin DBRole = "supabase_auth_admin" + DBRoleFunctionsAdmin DBRole = "supabase_functions_admin" + DBRoleStorageAdmin DBRole = "supabase_storage_admin" + DBRoleSupabaseAdmin DBRole = "supabase_admin" +) diff --git a/internal/supabase/env.go b/internal/supabase/env.go new file mode 100644 index 0000000..1ecdbde --- /dev/null +++ b/internal/supabase/env.go @@ -0,0 +1,269 @@ +package supabase + +import ( + "fmt" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type stringEnv string + +func (e stringEnv) Var(value string) corev1.EnvVar { + return corev1.EnvVar{ + Name: string(e), + Value: value, + } +} + +type stringSliceEnv struct { + key string + separator string +} + +func (e stringSliceEnv) Var(value []string) corev1.EnvVar { + return corev1.EnvVar{ + Name: e.key, + Value: strings.Join(value, e.separator), + } +} + +type intEnv string + +func (e intEnv) Var(value int) corev1.EnvVar { + return corev1.EnvVar{ + Name: string(e), + Value: strconv.Itoa(value), + } +} + +type boolEnv string + +func (e boolEnv) Var(value bool) corev1.EnvVar { + return corev1.EnvVar{ + Name: string(e), + Value: strconv.FormatBool(value), + } +} + +type secretEnv string + +func (e secretEnv) Var(sel *corev1.SecretKeySelector) corev1.EnvVar { + return corev1.EnvVar{ + Name: string(e), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: sel, + }, + } +} + +type serviceConfig[TEnvKeys, TDefaults any] struct { + Name string + EnvKeys TEnvKeys + Defaults TDefaults +} + +func (cfg serviceConfig[TEnvKeys, TDefaults]) ObjectName(obj metav1.Object) string { + return fmt.Sprintf("%s-%s", obj.GetName(), cfg.Name) +} + +func (cfg serviceConfig[TEnvKeys, TDefaults]) ObjectMeta(obj metav1.Object) metav1.ObjectMeta { + return metav1.ObjectMeta{Name: cfg.ObjectName(obj), Namespace: obj.GetNamespace()} +} + +type postgrestEnvKeys struct { + DBUri string + Schemas stringSliceEnv + AnonRole stringEnv + JWTSecret secretEnv + UseLegacyGucs boolEnv + ExtraSearchPath stringSliceEnv + AppSettingsJWTSecret secretEnv + AppSettingsJWTExpiry intEnv + AdminServerPort intEnv + MaxRows intEnv +} + +type postgrestConfigDefaults struct { + AnonRole string + Schemas []string + ExtraSearchPath []string +} + +type authEnvKeys struct { + ApiHost stringEnv + ApiPort intEnv + ApiExternalUrl stringEnv + DBDriver stringEnv + DatabaseUrl string + SiteUrl stringEnv + AdditionalRedirectURLs stringSliceEnv + DisableSignup boolEnv + JWTIssuer stringEnv + JWTAdminRoles stringEnv + JWTAudience stringEnv + JwtDefaultGroup stringEnv + JwtExpiry intEnv + JwtSecret secretEnv + EmailSignupDisabled boolEnv + MailerUrlPathsInvite stringEnv + MailerUrlPathsConfirmation stringEnv + MailerUrlPathsRecovery stringEnv + MailerUrlPathsEmailChange stringEnv + AnonymousUsersEnabled boolEnv +} + +type authConfigDefaults struct { + ApiHost string + ApiPort int + DbDriver string + JwtIssuer string + JwtAdminRoles string + JwtAudience string + JwtDefaultGroupName string + MailerUrlPathsInvite string + MailerUrlPathsConfirmation string + MailerUrlPathsRecovery string + MailerUrlPathsEmailChange string +} + +type pgMetaEnvKeys struct { + APIPort intEnv + DBHost stringEnv + DBPort intEnv + DBName stringEnv + DBUser secretEnv + DBPassword secretEnv +} + +type pgMetaDefaults struct { + APIPort int + DBPort string +} + +type envoyDefaults struct { + ConfigKey string +} + +type envoyServiceConfig struct { + Defaults envoyDefaults +} + +func (envoyServiceConfig) ObjectName(obj metav1.Object) string { + return fmt.Sprintf("%s-envoy", obj.GetName()) +} + +type jwtDefaults struct { + SecretKey string + JwksKey string + AnonKey string + ServiceKey string + SecretLength int + Expiry int +} + +type jwtConfig struct { + Defaults jwtDefaults +} + +func (jwtConfig) ObjectName(obj metav1.Object) string { + return fmt.Sprintf("%s-jwt", obj.GetName()) +} + +var ServiceConfig = struct { + Postgrest serviceConfig[postgrestEnvKeys, postgrestConfigDefaults] + Auth serviceConfig[authEnvKeys, authConfigDefaults] + PGMeta serviceConfig[pgMetaEnvKeys, pgMetaDefaults] + Envoy envoyServiceConfig + JWT jwtConfig +}{ + Postgrest: serviceConfig[postgrestEnvKeys, postgrestConfigDefaults]{ + Name: "postgrest", + EnvKeys: postgrestEnvKeys{ + DBUri: "PGRST_DB_URI", + Schemas: stringSliceEnv{key: "PGRST_DB_SCHEMAS", separator: ","}, + AnonRole: "PGRST_DB_ANON_ROLE", + JWTSecret: "PGRST_JWT_SECRET", + UseLegacyGucs: "PGRST_DB_USE_LEGACY_GUCS", + AppSettingsJWTSecret: "PGRST_APP_SETTINGS_JWT_SECRET", + AppSettingsJWTExpiry: "PGRST_APP_SETTINGS_JWT_EXP", + AdminServerPort: "PGRST_ADMIN_SERVER_PORT", + ExtraSearchPath: stringSliceEnv{key: "PGRST_DB_EXTRA_SEARCH_PATH", separator: ","}, + }, + Defaults: postgrestConfigDefaults{ + AnonRole: "anon", + Schemas: []string{"public", "graphql_public"}, + ExtraSearchPath: []string{"public", "extensions"}, + }, + }, + Auth: serviceConfig[authEnvKeys, authConfigDefaults]{ + Name: "auth", + EnvKeys: authEnvKeys{ + ApiHost: "GOTRUE_API_HOST", + ApiPort: "GOTRUE_API_PORT", + ApiExternalUrl: "API_EXTERNAL_URL", + DBDriver: "GOTRUE_DB_DRIVER", + DatabaseUrl: "GOTRUE_DB_DATABASE_URL", + SiteUrl: "GOTRUE_SITE_URL", + AdditionalRedirectURLs: stringSliceEnv{key: "GOTRUE_URI_ALLOW_LIST", separator: ","}, + DisableSignup: "GOTRUE_DISABLE_SIGNUP", + JWTIssuer: "GOTRUE_JWT_ISSUER", + JWTAdminRoles: "GOTRUE_JWT_ADMIN_ROLES", + JWTAudience: "GOTRUE_JWT_AUD", + JwtDefaultGroup: "GOTRUE_JWT_DEFAULT_GROUP_NAME", + JwtExpiry: "GOTRUE_JWT_EXP", + JwtSecret: "GOTRUE_JWT_SECRET", + EmailSignupDisabled: "GOTRUE_EXTERNAL_EMAIL_ENABLED", + MailerUrlPathsInvite: "MAILER_URLPATHS_INVITE", + MailerUrlPathsConfirmation: "MAILER_URLPATHS_CONFIRMATION", + MailerUrlPathsRecovery: "MAILER_URLPATHS_RECOVERY", + MailerUrlPathsEmailChange: "MAILER_URLPATHS_EMAIL_CHANGE", + AnonymousUsersEnabled: "GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED", + }, + Defaults: authConfigDefaults{ + ApiHost: "0.0.0.0", + ApiPort: 9999, + DbDriver: "postgres", + JwtIssuer: "supabase", + JwtAdminRoles: "service_role", + JwtAudience: "authenticated", + JwtDefaultGroupName: "authenticated", + MailerUrlPathsInvite: "/auth/v1/verify", + MailerUrlPathsConfirmation: "/auth/v1/verify", + MailerUrlPathsRecovery: "/auth/v1/verify", + MailerUrlPathsEmailChange: "/auth/v1/verify", + }, + }, + PGMeta: serviceConfig[pgMetaEnvKeys, pgMetaDefaults]{ + Name: "pg-meta", + EnvKeys: pgMetaEnvKeys{ + APIPort: "PG_META_PORT", + DBHost: "PG_META_DB_HOST", + DBPort: "PG_META_DB_PORT", + DBName: "PG_META_DB_NAME", + DBUser: "PG_META_DB_USER", + DBPassword: "PG_META_DB_PASSWORD", + }, + Defaults: pgMetaDefaults{ + APIPort: 8080, + DBPort: "5432", + }, + }, + Envoy: envoyServiceConfig{ + Defaults: envoyDefaults{ + "config.yaml", + }, + }, + JWT: jwtConfig{ + Defaults: jwtDefaults{ + SecretKey: "secret", + JwksKey: "jwks.json", + AnonKey: "anon_key", + ServiceKey: "service_key", + SecretLength: 40, + Expiry: 3600, + }, + }, +} diff --git a/internal/supabase/images.go b/internal/supabase/images.go index 33bf127..96608ef 100644 --- a/internal/supabase/images.go +++ b/internal/supabase/images.go @@ -1,5 +1,7 @@ package supabase +import "fmt" + type ImageRef struct { // The repository of the image Repository string @@ -7,57 +9,55 @@ type ImageRef struct { Tag string } -var Images = map[string]ImageRef{ - "analytics": { - Repository: "supabase/logflare", - Tag: "1.4.0", - }, - "auth": { - Repository: "supabase/gotrue", - Tag: "v2.164.0", - }, - "db": { - Repository: "supabase/postgres", - Tag: "15.6.1.146", - }, - "functions": { +func (r ImageRef) String() string { + return fmt.Sprintf("%s:%s", r.Repository, r.Tag) +} + +var Images = struct { + EdgeRuntime ImageRef + Envoy ImageRef + Gotrue ImageRef + ImgProxy ImageRef + PostgresMeta ImageRef + Postgrest ImageRef + Realtime ImageRef + Storage ImageRef + Studio ImageRef +}{ + EdgeRuntime: ImageRef{ Repository: "supabase/edge-runtime", Tag: "v1.65.3", }, - "imgproxy": { + Envoy: ImageRef{ + Repository: "envoyproxy/envoy", + Tag: "distroless-v1.32.3", + }, + Gotrue: ImageRef{ + Repository: "supabase/gotrue", + Tag: "v2.164.0", + }, + ImgProxy: ImageRef{ Repository: "darthsim/imgproxy", Tag: "v3.8.0", }, - "kong": { - Repository: "kong", - Tag: "2.8.1", - }, - "meta": { + PostgresMeta: ImageRef{ Repository: "supabase/postgres-meta", Tag: "v0.84.2", }, - "realtime": { - Repository: "supabase/realtime", - Tag: "v2.33.58", - }, - "rest": { + Postgrest: ImageRef{ Repository: "postgrest/postgrest", Tag: "v12.2.0", }, - "storage": { + Realtime: ImageRef{ + Repository: "supabase/realtime", + Tag: "v2.33.70", + }, + Storage: ImageRef{ Repository: "supabase/storage-api", Tag: "v1.11.13", }, - "studio": { + Studio: ImageRef{ Repository: "supabase/studio", Tag: "20241202-71e5240", }, - "supavisor": { - Repository: "supabase/supavisor", - Tag: "1.1.56", - }, - "vector": { - Repository: "timberio/vector", - Tag: "0.28.1-alpine", - }, } diff --git a/internal/supabase/jwt.go b/internal/supabase/jwt.go new file mode 100644 index 0000000..8139692 --- /dev/null +++ b/internal/supabase/jwt.go @@ -0,0 +1,19 @@ +package supabase + +import ( + "crypto/rand" + "encoding/hex" +) + +func RandomJWTSecret() ([]byte, error) { + jwtSecretBytes := make([]byte, ServiceConfig.JWT.Defaults.SecretLength) + + if _, err := rand.Read(jwtSecretBytes); err != nil { + return nil, err + } + + jwtSecretHex := make([]byte, hex.EncodedLen(len(jwtSecretBytes))) + hex.Encode(jwtSecretHex, jwtSecretBytes) + + return jwtSecretHex, nil +} diff --git a/internal/webhook/v1alpha1/apigateway_webhook_defaulter.go b/internal/webhook/v1alpha1/apigateway_webhook_defaulter.go new file mode 100644 index 0000000..f94bf34 --- /dev/null +++ b/internal/webhook/v1alpha1/apigateway_webhook_defaulter.go @@ -0,0 +1,102 @@ +/* +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 ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1" + "code.icb4dc0.de/prskr/supabase-operator/internal/supabase" +) + +// +kubebuilder:webhook:path=/mutate-supabase-k8s-icb4dc0-de-v1alpha1-apigateway,mutating=true,failurePolicy=fail,sideEffects=None,groups=supabase.k8s.icb4dc0.de,resources=apigateways,verbs=create;update,versions=v1alpha1,name=mapigateway-v1alpha1.kb.io,admissionReviewVersions=v1 + +// APIGatewayCustomDefaulter struct is responsible for setting default values on the custom resource of the +// Kind APIGateway when those are created or updated. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as it is used only for temporary operations and does not need to be deeply copied. +type APIGatewayCustomDefaulter struct { + CurrentNamespace string + Recorder record.EventRecorder +} + +var _ webhook.CustomDefaulter = &APIGatewayCustomDefaulter{} + +// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind APIGateway. +func (d *APIGatewayCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error { + const ( + defaultManagerNamespace = "supabase-system" + ) + + apiGateway, ok := obj.(*supabasev1alpha1.APIGateway) + + if !ok { + return fmt.Errorf("expected an APIGateway object but got %T", obj) + } + apigatewaylog.Info("Defaulting for APIGateway", "name", apiGateway.GetName()) + + if apiGateway.Spec.JWKSSelector == nil { + apiGateway.Spec.JWKSSelector = &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: supabase.ServiceConfig.JWT.ObjectName(apiGateway), + }, + Key: supabase.ServiceConfig.JWT.Defaults.JwksKey, + } + } + + if apiGateway.Spec.Envoy == nil { + apiGateway.Spec.Envoy = new(supabasev1alpha1.EnvoySpec) + } + + if apiGateway.Spec.Envoy.ControlPlane == nil { + if d.CurrentNamespace == defaultManagerNamespace { + d.Recorder.Event( + apiGateway, + corev1.EventTypeNormal, + "Guessing Envoy control plane endpoint", + "Making guess of control plane config, most likely this is correct as the current namespace is the default namespace where the operator is deployed but of course it could be wrong as well", + ) + + apiGateway.Spec.Envoy.ControlPlane = &supabasev1alpha1.ControlPlaneSpec{ + Host: "supabase-control-plane.supabase-system.svc", + Port: 18000, + } + } else { + d.Recorder.Eventf( + apiGateway, + corev1.EventTypeWarning, + "Guessing Envoy control plane endpoint", + "Making guess of control plane config based on the namespace of the manager (%s) - could be wrong if control plane was manually deployed to another namespace", + d.CurrentNamespace, + ) + + apiGateway.Spec.Envoy.ControlPlane = &supabasev1alpha1.ControlPlaneSpec{ + Host: fmt.Sprintf("supabase-control-plane.%s.svc", d.CurrentNamespace), + Port: 18000, + } + } + } + + return nil +} diff --git a/internal/webhook/v1alpha1/apigateway_webhook_test.go b/internal/webhook/v1alpha1/apigateway_webhook_test.go new file mode 100644 index 0000000..1cd960e --- /dev/null +++ b/internal/webhook/v1alpha1/apigateway_webhook_test.go @@ -0,0 +1,87 @@ +/* +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 ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1" + // TODO (user): Add any additional imports if needed +) + +var _ = Describe("APIGateway Webhook", func() { + var ( + obj *supabasev1alpha1.APIGateway + oldObj *supabasev1alpha1.APIGateway + validator APIGatewayCustomValidator + defaulter APIGatewayCustomDefaulter + ) + + BeforeEach(func() { + obj = &supabasev1alpha1.APIGateway{} + oldObj = &supabasev1alpha1.APIGateway{} + validator = APIGatewayCustomValidator{} + Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") + defaulter = APIGatewayCustomDefaulter{} + Expect(defaulter).NotTo(BeNil(), "Expected defaulter to be initialized") + Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") + Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") + // TODO (user): Add any setup logic common to all tests + }) + + AfterEach(func() { + // TODO (user): Add any teardown logic common to all tests + }) + + Context("When creating APIGateway under Defaulting Webhook", func() { + // TODO (user): Add logic for defaulting webhooks + // Example: + // It("Should apply defaults when a required field is empty", func() { + // By("simulating a scenario where defaults should be applied") + // obj.SomeFieldWithDefault = "" + // By("calling the Default method to apply defaults") + // defaulter.Default(ctx, obj) + // By("checking that the default values are set") + // Expect(obj.SomeFieldWithDefault).To(Equal("default_value")) + // }) + }) + + Context("When creating or updating APIGateway under Validating Webhook", func() { + // TODO (user): Add logic for validating webhooks + // Example: + // It("Should deny creation if a required field is missing", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "" + // Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred()) + // }) + // + // It("Should admit creation if all required fields are present", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "valid_value" + // Expect(validator.ValidateCreate(ctx, obj)).To(BeNil()) + // }) + // + // It("Should validate updates correctly", func() { + // By("simulating a valid update scenario") + // oldObj.SomeRequiredField = "updated_value" + // obj.SomeRequiredField = "updated_value" + // Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil()) + // }) + }) + +}) diff --git a/internal/webhook/v1alpha1/apigateway_webhook_validator.go b/internal/webhook/v1alpha1/apigateway_webhook_validator.go new file mode 100644 index 0000000..20469d6 --- /dev/null +++ b/internal/webhook/v1alpha1/apigateway_webhook_validator.go @@ -0,0 +1,97 @@ +/* +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 ( + "context" + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1" +) + +// nolint:unused +// log is for logging in this package. +var apigatewaylog = logf.Log.WithName("apigateway-resource") + +var ( + ErrMissingEnvoySpec = errors.New("envoy needs to be configured") + ErrMissingControlPlaneSpec = errors.New("envoy control plane needs to be configured") +) + +// +kubebuilder:webhook:path=/validate-supabase-k8s-icb4dc0-de-v1alpha1-apigateway,mutating=false,failurePolicy=fail,sideEffects=None,groups=supabase.k8s.icb4dc0.de,resources=apigateways,verbs=create;update,versions=v1alpha1,name=vapigateway-v1alpha1.kb.io,admissionReviewVersions=v1 + +// APIGatewayCustomValidator struct is responsible for validating the APIGateway resource +// when it is created, updated, or deleted. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as this struct is used only for temporary operations and does not need to be deeply copied. +type APIGatewayCustomValidator struct{} + +var _ webhook.CustomValidator = &APIGatewayCustomValidator{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type APIGateway. +func (v *APIGatewayCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + apigateway, ok := obj.(*supabasev1alpha1.APIGateway) + if !ok { + return nil, fmt.Errorf("expected a APIGateway object but got %T", obj) + } + apigatewaylog.Info("Validation for APIGateway upon creation", "name", apigateway.GetName()) + + return validateEnvoyControlPlane(apigateway) +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type APIGateway. +func (v *APIGatewayCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + apigateway, ok := newObj.(*supabasev1alpha1.APIGateway) + if !ok { + return nil, fmt.Errorf("expected a APIGateway object for the newObj but got %T", newObj) + } + apigatewaylog.Info("Validation for APIGateway upon update", "name", apigateway.GetName()) + + return validateEnvoyControlPlane(apigateway) +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type APIGateway. +func (v *APIGatewayCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + apigateway, ok := obj.(*supabasev1alpha1.APIGateway) + if !ok { + return nil, fmt.Errorf("expected a APIGateway object but got %T", obj) + } + apigatewaylog.Info("Validation for APIGateway upon deletion", "name", apigateway.GetName()) + + return nil, nil +} + +func validateEnvoyControlPlane(gateway *supabasev1alpha1.APIGateway) (admission.Warnings, error) { + envoySpec := gateway.Spec.Envoy + + if envoySpec == nil { + return nil, ErrMissingEnvoySpec + } + + if envoySpec.ControlPlane == nil { + return nil, ErrMissingControlPlaneSpec + } + + return nil, nil +} diff --git a/internal/webhook/v1alpha1/core_webhook_defaulter.go b/internal/webhook/v1alpha1/core_webhook_defaulter.go new file mode 100644 index 0000000..2bd739e --- /dev/null +++ b/internal/webhook/v1alpha1/core_webhook_defaulter.go @@ -0,0 +1,200 @@ +/* +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 ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "maps" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + 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" +) + +// +kubebuilder:webhook:path=/mutate-supabase-k8s-icb4dc0-de-v1alpha1-core,mutating=true,failurePolicy=fail,sideEffects=None,groups=supabase.k8s.icb4dc0.de,resources=cores,verbs=create;update,versions=v1alpha1,name=mcore-v1alpha1.kb.io,admissionReviewVersions=v1 + +// CoreCustomDefaulter struct is responsible for setting default values on the custom resource of the +// Kind Core when those are created or updated. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as it is used only for temporary operations and does not need to be deeply copied. +type CoreCustomDefaulter struct { + client.Client +} + +var _ webhook.CustomDefaulter = &CoreCustomDefaulter{} + +// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind Core. +func (d *CoreCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error { + core, ok := obj.(*supabasev1alpha1.Core) + + if !ok { + return fmt.Errorf("expected an Core object but got %T", obj) + } + corelog.Info("Defaulting for Core", "name", core.GetName()) + + if err := d.defaultJWT(ctx, core); err != nil { + return fmt.Errorf("ensuring JWT secret: %w", err) + } + + // TODO copy plain text DSN to secret if present + + corelog.Info("Defaulting database roles") + if !core.Spec.Database.Roles.SelfManaged { + const roleCredsSecretNameTemplate = "db-roles-creds-%s" + if core.Spec.Database.Roles.Secrets.Admin == nil { + corelog.Info("Defaulting role", "role_name", supabase.DBRoleSupabaseAdmin) + core.Spec.Database.Roles.Secrets.Admin = &corev1.LocalObjectReference{ + Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleSupabaseAdmin.K8sString()), + } + } + + if core.Spec.Database.Roles.Secrets.Authenticator == nil { + corelog.Info("Defaulting role", "role_name", supabase.DBRoleAuthenticator) + core.Spec.Database.Roles.Secrets.Authenticator = &corev1.LocalObjectReference{ + Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleAuthenticator.K8sString()), + } + } + + if core.Spec.Database.Roles.Secrets.AuthAdmin == nil { + corelog.Info("Defaulting role", "role_name", supabase.DBRoleAuthAdmin) + core.Spec.Database.Roles.Secrets.AuthAdmin = &corev1.LocalObjectReference{ + Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleAuthAdmin.K8sString()), + } + } + + if core.Spec.Database.Roles.Secrets.FunctionsAdmin == nil { + corelog.Info("Defaulting role", "role_name", supabase.DBRoleFunctionsAdmin) + core.Spec.Database.Roles.Secrets.FunctionsAdmin = &corev1.LocalObjectReference{ + Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleFunctionsAdmin.K8sString()), + } + } + + if core.Spec.Database.Roles.Secrets.StorageAdmin == nil { + corelog.Info("Defaulting role", "role_name", supabase.DBRoleStorageAdmin) + core.Spec.Database.Roles.Secrets.StorageAdmin = &corev1.LocalObjectReference{ + Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleStorageAdmin.K8sString()), + } + } + } + + return nil +} + +func (d *CoreCustomDefaulter) defaultJWT(ctx context.Context, core *supabasev1alpha1.Core) error { + corelog.Info("Defaulting JWT") + + if core.Spec.JWT == nil { + core.Spec.JWT = new(supabasev1alpha1.JwtSpec) + } + + if core.Spec.JWT.SecretRef == nil { + core.Spec.JWT.SecretRef = &corev1.LocalObjectReference{ + Name: supabase.ServiceConfig.JWT.ObjectName(core), + } + } + + if core.Spec.JWT.SecretKey == "" { + core.Spec.JWT.SecretKey = supabase.ServiceConfig.JWT.Defaults.SecretKey + } + + if core.Spec.JWT.JwksKey == "" { + core.Spec.JWT.JwksKey = supabase.ServiceConfig.JWT.Defaults.JwksKey + } + + if core.Spec.JWT.AnonKey == "" { + core.Spec.JWT.AnonKey = supabase.ServiceConfig.JWT.Defaults.AnonKey + } + + if core.Spec.JWT.ServiceKey == "" { + core.Spec.JWT.ServiceKey = supabase.ServiceConfig.JWT.Defaults.ServiceKey + } + + jwtSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: core.Spec.JWT.SecretRef.Name, + Namespace: core.Namespace, + }, + } + + if core.Spec.JWT.Secret == nil { + return nil + } + + _, err := controllerutil.CreateOrUpdate(ctx, d.Client, jwtSecret, func() error { + jwtSecret.Labels = maps.Clone(core.Labels) + if jwtSecret.Labels == nil { + jwtSecret.Labels = make(map[string]string) + } + + jwtSecret.Labels[meta.SupabaseLabel.Reload] = "" + + if jwtSecret.Data == nil { + jwtSecret.Data = make(map[string][]byte, 2) + } + + var ( + plainSecret = core.Spec.JWT.Secret + expectedSecret = make([]byte, hex.EncodedLen(len(*plainSecret))) + secretChanged bool + ) + + hex.Encode(expectedSecret, []byte(*plainSecret)) + currentSecret, ok := jwtSecret.Data[core.Spec.JWT.SecretKey] + if !ok { + jwtSecret.Data[core.Spec.JWT.SecretKey] = expectedSecret + secretChanged = true + } else if !bytes.Equal(expectedSecret, currentSecret) { + secretChanged = true + jwtSecret.Data[core.Spec.JWT.SecretKey] = expectedSecret + } + + core.Spec.JWT.Secret = nil + + if _, ok := jwtSecret.Data[core.Spec.JWT.JwksKey]; !ok || secretChanged { + 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 + } + + return nil + }) + + return err +} diff --git a/internal/webhook/v1alpha1/core_webhook_test.go b/internal/webhook/v1alpha1/core_webhook_test.go new file mode 100644 index 0000000..a3fcf95 --- /dev/null +++ b/internal/webhook/v1alpha1/core_webhook_test.go @@ -0,0 +1,87 @@ +/* +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 ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1" + // TODO (user): Add any additional imports if needed +) + +var _ = Describe("Core Webhook", func() { + var ( + obj *supabasev1alpha1.Core + oldObj *supabasev1alpha1.Core + validator CoreCustomValidator + defaulter CoreCustomDefaulter + ) + + BeforeEach(func() { + obj = &supabasev1alpha1.Core{} + oldObj = &supabasev1alpha1.Core{} + validator = CoreCustomValidator{} + Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") + defaulter = CoreCustomDefaulter{} + Expect(defaulter).NotTo(BeNil(), "Expected defaulter to be initialized") + Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") + Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") + // TODO (user): Add any setup logic common to all tests + }) + + AfterEach(func() { + // TODO (user): Add any teardown logic common to all tests + }) + + Context("When creating Core under Defaulting Webhook", func() { + // TODO (user): Add logic for defaulting webhooks + // Example: + // It("Should apply defaults when a required field is empty", func() { + // By("simulating a scenario where defaults should be applied") + // obj.SomeFieldWithDefault = "" + // By("calling the Default method to apply defaults") + // defaulter.Default(ctx, obj) + // By("checking that the default values are set") + // Expect(obj.SomeFieldWithDefault).To(Equal("default_value")) + // }) + }) + + Context("When creating or updating Core under Validating Webhook", func() { + // TODO (user): Add logic for validating webhooks + // Example: + // It("Should deny creation if a required field is missing", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "" + // Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred()) + // }) + // + // It("Should admit creation if all required fields are present", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "valid_value" + // Expect(validator.ValidateCreate(ctx, obj)).To(BeNil()) + // }) + // + // It("Should validate updates correctly", func() { + // By("simulating a valid update scenario") + // oldObj.SomeRequiredField = "updated_value" + // obj.SomeRequiredField = "updated_value" + // Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil()) + // }) + }) + +}) diff --git a/internal/webhook/v1alpha1/core_webhook_validator.go b/internal/webhook/v1alpha1/core_webhook_validator.go new file mode 100644 index 0000000..a7e664e --- /dev/null +++ b/internal/webhook/v1alpha1/core_webhook_validator.go @@ -0,0 +1,175 @@ +/* +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 ( + "context" + "errors" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1" + "code.icb4dc0.de/prskr/supabase-operator/internal/supabase" +) + +// nolint:unused +// log is for logging in this package. +var corelog = logf.Log.WithName("core-resource") + +var ( + ErrNoDSN = errors.New("neither DSN nor DSNFrom are set - either one needs to be specified") + ErrManagedCredentialsNotSpecified = errors.New("credentials are not set which is required when self managing DB roles") + ErrManagedCredentialsSecretMissing = errors.New("secret does not exist") +) + +// +kubebuilder:webhook:path=/validate-supabase-k8s-icb4dc0-de-v1alpha1-core,mutating=false,failurePolicy=fail,sideEffects=None,groups=supabase.k8s.icb4dc0.de,resources=cores,verbs=create;update,versions=v1alpha1,name=vcore-v1alpha1.kb.io,admissionReviewVersions=v1 + +// CoreCustomValidator struct is responsible for validating the Core resource +// when it is created, updated, or deleted. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as this struct is used only for temporary operations and does not need to be deeply copied. +type CoreCustomValidator struct { + client.Client +} + +var _ webhook.CustomValidator = &CoreCustomValidator{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type Core. +func (v *CoreCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + core, ok := obj.(*supabasev1alpha1.Core) + if !ok { + return nil, fmt.Errorf("expected a Core object but got %T", obj) + } + corelog.Info("Validation for Core upon creation", "name", core.GetName()) + + warns, err := v.validateDb(ctx, core) + if err != nil { + return nil, err + } + + return warns, nil +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type Core. +func (v *CoreCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + core, ok := newObj.(*supabasev1alpha1.Core) + if !ok { + return nil, fmt.Errorf("expected a Core object for the newObj but got %T", newObj) + } + corelog.Info("Validation for Core upon update", "name", core.GetName()) + + warns, err := v.validateDb(ctx, core) + if err != nil { + return nil, err + } + + return warns, nil +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type Core. +func (v *CoreCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + core, ok := obj.(*supabasev1alpha1.Core) + if !ok { + return nil, fmt.Errorf("expected a Core object but got %T", obj) + } + corelog.Info("Validation for Core upon deletion", "name", core.GetName()) + + warns, err := v.validateDb(ctx, core) + if err != nil { + return nil, err + } + + return warns, nil +} + +func (v *CoreCustomValidator) validateDb( + ctx context.Context, + core *supabasev1alpha1.Core, +) (warnings admission.Warnings, err error) { + dbSpec := core.Spec.Database + + if dbSpec.DSN != nil && dbSpec.DSNSecretRef == nil { + return warnings, ErrNoDSN + } + + if dbSpec.Roles.SelfManaged { + doesSecretExists := func(ctx context.Context, name string) (exists bool, err error) { + var secret corev1.Secret + if err := v.Client.Get(ctx, types.NamespacedName{Namespace: core.Namespace, Name: name}, &secret); err == nil { + return true, nil + } else if client.IgnoreNotFound(err) == nil { + return false, nil + } else { + return false, err + } + } + + if authenticator := dbSpec.Roles.Secrets.Authenticator; authenticator == nil { + return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsNotSpecified, supabase.DBRoleAuthenticator) + } else { + exists, err := doesSecretExists(ctx, authenticator.Name) + if err != nil { + return warnings, err + } else if !exists { + return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, authenticator.Name) + } + } + + if authAdmin := dbSpec.Roles.Secrets.AuthAdmin; authAdmin == nil { + return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsNotSpecified, supabase.DBRoleAuthAdmin) + } else { + exists, err := doesSecretExists(ctx, authAdmin.Name) + if err != nil { + return warnings, err + } else if !exists { + return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, authAdmin.Name) + } + } + + if functionsAdmin := dbSpec.Roles.Secrets.FunctionsAdmin; functionsAdmin == nil { + return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsNotSpecified, supabase.DBRoleFunctionsAdmin) + } else { + exists, err := doesSecretExists(ctx, functionsAdmin.Name) + if err != nil { + return warnings, err + } else if !exists { + return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, functionsAdmin.Name) + } + } + + if storageAdmin := dbSpec.Roles.Secrets.StorageAdmin; storageAdmin == nil { + return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsNotSpecified, supabase.DBRoleStorageAdmin) + } else { + exists, err := doesSecretExists(ctx, storageAdmin.Name) + if err != nil { + return warnings, err + } else if !exists { + return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, storageAdmin.Name) + } + } + } + + return warnings, nil +} diff --git a/internal/webhook/v1alpha1/setup.go b/internal/webhook/v1alpha1/setup.go new file mode 100644 index 0000000..181c7bd --- /dev/null +++ b/internal/webhook/v1alpha1/setup.go @@ -0,0 +1,31 @@ +package v1alpha1 + +import ( + ctrl "sigs.k8s.io/controller-runtime" + + supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1" +) + +type WebhookConfig struct { + CurrentNamespace string +} + +// SetupAPIGatewayWebhookWithManager registers the webhook for APIGateway in the manager. +func SetupAPIGatewayWebhookWithManager(mgr ctrl.Manager, cfg WebhookConfig) error { + mgr.GetEventRecorderFor("apigateway-defaulter") + return ctrl.NewWebhookManagedBy(mgr).For(&supabasev1alpha1.APIGateway{}). + WithValidator(&APIGatewayCustomValidator{}). + WithDefaulter(&APIGatewayCustomDefaulter{ + CurrentNamespace: cfg.CurrentNamespace, + Recorder: mgr.GetEventRecorderFor("apigateway-defaulter"), + }). + Complete() +} + +// SetupCoreWebhookWithManager registers the webhook for Core in the manager. +func SetupCoreWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr).For(&supabasev1alpha1.Core{}). + WithValidator(&CoreCustomValidator{Client: mgr.GetClient()}). + WithDefaulter(&CoreCustomDefaulter{Client: mgr.GetClient()}). + Complete() +} diff --git a/internal/webhook/v1alpha1/webhook_suite_test.go b/internal/webhook/v1alpha1/webhook_suite_test.go new file mode 100644 index 0000000..412fceb --- /dev/null +++ b/internal/webhook/v1alpha1/webhook_suite_test.go @@ -0,0 +1,153 @@ +/* +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 ( + "context" + "crypto/tls" + "fmt" + "net" + "path/filepath" + "runtime" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + admissionv1 "k8s.io/api/admission/v1" + + supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1" + + // +kubebuilder:scaffold:imports + apimachineryruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + cancel context.CancelFunc + cfg *rest.Config + ctx context.Context + k8sClient client.Client + testEnv *envtest.Environment +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Webhook Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: false, + + // The BinaryAssetsDirectory is only required if you want to run the tests directly + // without call the makefile target test. If not informed it will look for the + // default path defined in controller-runtime which is /usr/local/kubebuilder/. + // Note that you must have the required binaries setup under the bin directory to perform + // the tests directly. When we run make test it will be setup and used automatically. + BinaryAssetsDirectory: filepath.Join("..", "..", "..", "bin", "k8s", + fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)), + + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")}, + }, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + scheme := apimachineryruntime.NewScheme() + err = supabasev1alpha1.AddToScheme(scheme) + Expect(err).NotTo(HaveOccurred()) + + err = admissionv1.AddToScheme(scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // start webhook server using Manager. + webhookInstallOptions := &testEnv.WebhookInstallOptions + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + WebhookServer: webhook.NewServer(webhook.Options{ + Host: webhookInstallOptions.LocalServingHost, + Port: webhookInstallOptions.LocalServingPort, + CertDir: webhookInstallOptions.LocalServingCertDir, + }), + LeaderElection: false, + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).NotTo(HaveOccurred()) + + err = SetupCoreWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + + err = SetupAPIGatewayWebhookWithManager(mgr, WebhookConfig{CurrentNamespace: "default"}) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:webhook + + go func() { + defer GinkgoRecover() + err = mgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + // wait for the webhook server to get ready. + dialer := &net.Dialer{Timeout: time.Second} + addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) + Eventually(func() error { + conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + return err + } + + return conn.Close() + }).Should(Succeed()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/mage.sh b/mage.sh new file mode 100755 index 0000000..14d493c --- /dev/null +++ b/mage.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +go run mage.go $@ diff --git a/magefiles/build.go b/magefiles/build.go index c49a783..eae23d8 100644 --- a/magefiles/build.go +++ b/magefiles/build.go @@ -1,8 +1,9 @@ package main import ( + "log/slog" + "github.com/magefile/mage/mg" // mg contains helpful utility functions, like Deps - "golang.org/x/exp/slog" ) // Default target to run when none is specified diff --git a/magefiles/commands.go b/magefiles/commands.go index ae8789a..f0946b0 100644 --- a/magefiles/commands.go +++ b/magefiles/commands.go @@ -10,12 +10,16 @@ var ( ControllerGen = command("controller-gen") Gofumpt = command("gofumpt") GolangciLint = command("golangci-lint") + Sqlc = command("sqlc") + CRDRefDocs = command("crd-ref-docs") ) var tools map[command]string = map[command]string{ ControllerGen: "sigs.k8s.io/controller-tools/cmd/controller-gen", Gofumpt: "mvdan.cc/gofumpt", GolangciLint: "github.com/golangci/golangci-lint/cmd/golangci-lint", + Sqlc: "github.com/sqlc-dev/sqlc/cmd/sqlc", + CRDRefDocs: "github.com/elastic/crd-ref-docs", } var ( diff --git a/magefiles/generate.go b/magefiles/generate.go index f7cb6d3..266e7f6 100644 --- a/magefiles/generate.go +++ b/magefiles/generate.go @@ -2,6 +2,8 @@ package main import ( "context" + "errors" + "fmt" "io/fs" "log/slog" "net/http" @@ -20,22 +22,32 @@ const ( ) func GenerateAll(ctx context.Context) { - mg.CtxDeps(ctx, FetchImageMeta, FetchMigrations, Manifests, Generate) + mg.CtxDeps(ctx, FetchImageMeta, FetchMigrations, CRDs, CRDDocs) } -func Manifests() error { - return RunTool( - tools[ControllerGen], - "rbac:roleName=manager-role", - "crd", - "webhook", - `paths="./..."`, - "output:crd:artifacts:config=config/crd/bases", +func CRDs() error { + return errors.Join( + RunTool( + tools[ControllerGen], + "rbac:roleName=manager-role", + "crd", + "webhook", + `paths="./..."`, + "output:crd:artifacts:config=config/crd/bases", + ), + RunTool(tools[ControllerGen], `object:headerFile="hack/boilerplate.go.txt"`, `paths="./..."`), ) } -func Generate() error { - return RunTool(tools[ControllerGen], `object:headerFile="hack/boilerplate.go.txt"`, `paths="./..."`) +func CRDDocs() error { + return RunTool( + tools[CRDRefDocs], + "--source-path=./api/", + "--renderer=markdown", + "--config=crd-docs.yaml", + "--output-path=./docs/api/", + "--output-mode=group", + ) } func FetchImageMeta(ctx context.Context) error { @@ -73,6 +85,17 @@ func FetchImageMeta(ctx context.Context) error { Tag string } + serviceMappings := map[string]string{ + "auth": "Gotrue", + "functions": "EdgeRuntime", + "imgproxy": "ImgProxy", + "meta": "PostgresMeta", + "realtime": "Realtime", + "rest": "Postgrest", + "storage": "Storage", + "studio": "Studio", + } + templateData := struct { Images map[string]imageRef }{ @@ -83,13 +106,37 @@ func FetchImageMeta(ctx context.Context) error { splitIdx := strings.LastIndex(service.Image, ":") repo := service.Image[:splitIdx] tag := service.Image[splitIdx+1:] - templateData.Images[name] = imageRef{ + + mapping, ok := serviceMappings[name] + if !ok { + continue + } + + templateData.Images[mapping] = imageRef{ Repository: repo, Tag: tag, } } - return templates.ExecuteTemplate(f, "images.go.tmpl", templateData) + latestEnvoyTag, err := latestReleaseVersion(ctx, "envoyproxy", "envoy") + if err != nil { + return err + } + + templateData.Images["Envoy"] = imageRef{ + Repository: "envoyproxy/envoy", + Tag: fmt.Sprintf("distroless-%s", latestEnvoyTag), + } + + if err := templates.ExecuteTemplate(f, "images.go.tmpl", templateData); err != nil { + return err + } + + if err := f.Sync(); err != nil { + return err + } + + return RunTool(tools[Gofumpt], "-l", "-w", f.Name()) } func FetchMigrations(ctx context.Context) error { diff --git a/magefiles/templates/images.go.tmpl b/magefiles/templates/images.go.tmpl index e426f8b..c1d6783 100644 --- a/magefiles/templates/images.go.tmpl +++ b/magefiles/templates/images.go.tmpl @@ -1,5 +1,7 @@ package supabase +import "fmt" + type ImageRef struct { // The repository of the image Repository string @@ -7,9 +9,17 @@ type ImageRef struct { Tag string } -var Images = map[string]ImageRef{ +func (r ImageRef) String() string { + return fmt.Sprintf("%s:%s", r.Repository, r.Tag) +} + +var Images = struct{ {{- range $name, $image := .Images }} - "{{$name}}": { + {{$name}} ImageRef +{{- end }} +}{ +{{- range $name, $image := .Images }} + {{$name}}: ImageRef{ Repository: "{{$image.Repository}}", Tag: "{{$image.Tag}}", }, diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..85dd5dc --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,26 @@ +site_name: Supabase Operator +site_url: https://docs.supabase-operator.icb4dc0.de/ +theme: + name: material + custom_dir: docs/overrides + features: + - navigation.instant + - search.suggest + - search.highlight + - toc.integrate + +markdown_extensions: + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + +plugins: + - search + +nav: + - Home: + - About: index.md + - Getting Started: getting_started.md + - API Reference: api/supabase.k8s.icb4dc0.de.md diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2d2fc8b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Pygments +mkdocs-material diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 8e0c862..f33bc35 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -234,6 +234,44 @@ var _ = Describe("Manager", Ordered, func() { )) }) + It("should provisioned cert-manager", func() { + By("validating that cert-manager has the certificate Secret") + verifyCertManager := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "secrets", "webhook-server-cert", "-n", namespace) + _, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + } + Eventually(verifyCertManager).Should(Succeed()) + }) + + It("should have CA injection for mutating webhooks", func() { + By("checking CA injection for mutating webhooks") + verifyCAInjection := func(g Gomega) { + cmd := exec.Command("kubectl", "get", + "mutatingwebhookconfigurations.admissionregistration.k8s.io", + "supabase-operator-mutating-webhook-configuration", + "-o", "go-template={{ range .webhooks }}{{ .clientConfig.caBundle }}{{ end }}") + mwhOutput, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(len(mwhOutput)).To(BeNumerically(">", 10)) + } + Eventually(verifyCAInjection).Should(Succeed()) + }) + + It("should have CA injection for validating webhooks", func() { + By("checking CA injection for validating webhooks") + verifyCAInjection := func(g Gomega) { + cmd := exec.Command("kubectl", "get", + "validatingwebhookconfigurations.admissionregistration.k8s.io", + "supabase-operator-validating-webhook-configuration", + "-o", "go-template={{ range .webhooks }}{{ .clientConfig.caBundle }}{{ end }}") + vwhOutput, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(len(vwhOutput)).To(BeNumerically(">", 10)) + } + Eventually(verifyCAInjection).Should(Succeed()) + }) + // +kubebuilder:scaffold:e2e-webhooks-checks // TODO: Customize the e2e test suite with scenarios specific to your project. diff --git a/tools/go.mod b/tools/go.mod index f28bd4c..1431195 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -3,6 +3,7 @@ module tools go 1.23.4 require ( + github.com/elastic/crd-ref-docs v0.1.0 github.com/golangci/golangci-lint v1.62.2 mvdan.cc/gofumpt v0.7.0 sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20241206182001-aea2e32a9365 @@ -22,7 +23,10 @@ require ( github.com/Crocmagnon/fatcontext v0.5.3 // indirect github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/sprig v2.22.0+incompatible // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.0 // indirect github.com/alecthomas/go-check-sumtype v0.2.0 // indirect github.com/alexkohler/nakedret/v2 v2.0.5 // indirect @@ -75,6 +79,7 @@ require ( github.com/go-xmlfmt/xmlfmt v1.1.2 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-yaml v1.11.3 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -90,6 +95,7 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gordonklaus/ineffassign v0.1.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.4.2 // indirect @@ -98,6 +104,8 @@ require ( github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/huandu/xstrings v1.3.3 // indirect + github.com/imdario/mergo v0.3.11 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jgautheron/goconst v1.7.1 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect @@ -125,8 +133,10 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgechev/revive v1.5.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect @@ -200,7 +210,8 @@ require ( go-simpler.org/sloglint v0.7.2 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.26.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.29.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/exp/typeparams v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/mod v0.22.0 // indirect @@ -209,6 +220,7 @@ require ( golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.20.0 // indirect golang.org/x/tools v0.27.0 // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/protobuf v1.35.1 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/tools/go.sum b/tools/go.sum index 3202820..43e982a 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -20,8 +20,14 @@ github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rW github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.0 h1:/fTUt5vmbkAcMBt4YQiuC23cV0kEsN1MVMNqeOW43cU= github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.0/go.mod h1:ONJg5sxcbsdQQ4pOW8TGdTidT2TMAUy/2Xhr8mrYaao= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= +github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/OpenPeeDeeP/depguard/v2 v2.2.0 h1:vDfG60vDtIuf0MEOhmLlLLSzqaRM8EMcgJPdp74zmpA= github.com/OpenPeeDeeP/depguard/v2 v2.2.0/go.mod h1:CIzddKRvLBC4Au5aYP/i3nyaWQ+ClszLIuVocRiCYFQ= github.com/alecthomas/assert/v2 v2.2.2 h1:Z/iVC0xZfWTaFNE6bA3z07T86hd45Xe2eLt6WVy2bbk= @@ -82,6 +88,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= +github.com/elastic/crd-ref-docs v0.1.0 h1:Cr5kz89QB3Iuuj7dhAfLMApCrChEGAaIBTxGk/xuRKw= +github.com/elastic/crd-ref-docs v0.1.0/go.mod h1:X83mMBdJt05heJUYiS3T0yJ/JkCuliuhSUNav5Gjo/U= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -116,6 +124,12 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -147,6 +161,8 @@ github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-yaml v1.11.3 h1:B3W9IdWbvrUu2OYQGwvU1nZtvMQJPBKgBUuweJjLj6I= +github.com/goccy/go-yaml v1.11.3/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -188,6 +204,8 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +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/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= @@ -209,6 +227,10 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 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/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk= @@ -251,6 +273,8 @@ github.com/ldez/gomoddirectives v0.2.4 h1:j3YjBIjEBbqZ0NKtBNzr8rtMHTOrLPeiwTkfUJ github.com/ldez/gomoddirectives v0.2.4/go.mod h1:oWu9i62VcQDYp9EQ0ONTfqLNh+mDLWWDO+SO0qSQw5g= github.com/ldez/tagliatelle v0.5.0 h1:epgfuYt9v0CG3fms0pEgIMNPuFf/LpPIfjk4kyqSioo= github.com/ldez/tagliatelle v0.5.0/go.mod h1:rj1HmWiL1MiKQuOONhd09iySTEkUuE/8+5jtPYz9xa4= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk= @@ -277,10 +301,14 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgechev/revive v1.5.1 h1:hE+QPeq0/wIzJwOphdVyUJ82njdd8Khp4fUIHGZHW3M= github.com/mgechev/revive v1.5.1/go.mod h1:lC9AhkJIBs5zwx8wkudyHrU+IJkrEKmpCmGMnIJPk4o= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -477,13 +505,15 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= @@ -596,6 +626,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -612,6 +644,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/tools.go b/tools/tools.go index 3b68815..3b3c270 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -3,6 +3,7 @@ package tools import ( + _ "github.com/elastic/crd-ref-docs" _ "github.com/golangci/golangci-lint/cmd/golangci-lint" _ "mvdan.cc/gofumpt" _ "sigs.k8s.io/controller-runtime/tools/setup-envtest"