Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@

# Re-include embedded SQL migration files
!internal/database/migrations/sql/*.sql
!internal/dashboard/web/*.html
20 changes: 20 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package main
import (
"crypto/tls"
"flag"
"net/http"
"os"
"time"

// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
Expand All @@ -15,12 +17,14 @@ import (
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/manager"
"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 "github.com/strrl/supabase-operator/api/v1alpha1"
"github.com/strrl/supabase-operator/internal/controller"
"github.com/strrl/supabase-operator/internal/dashboard"
internalwebhook "github.com/strrl/supabase-operator/internal/webhook"
// +kubebuilder:scaffold:imports
)
Expand All @@ -44,12 +48,14 @@ func main() {
var webhookCertPath, webhookCertName, webhookCertKey string
var enableLeaderElection bool
var probeAddr string
var dashboardAddr 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.StringVar(&dashboardAddr, "dashboard-bind-address", ":8080", "The address the dashboard 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.")
Expand Down Expand Up @@ -178,6 +184,20 @@ func main() {
}
// +kubebuilder:scaffold:builder

dashboardShutdownTimeout := 5 * time.Second
if err := mgr.Add(&manager.Server{
Name: "dashboard",
Server: &http.Server{
Addr: dashboardAddr,
Handler: dashboard.NewHandler(mgr.GetAPIReader()),
ReadHeaderTimeout: 5 * time.Second,
},
ShutdownTimeout: &dashboardShutdownTimeout,
}); err != nil {
setupLog.Error(err, "unable to create dashboard")
os.Exit(1)
}

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up health check")
os.Exit(1)
Expand Down
18 changes: 18 additions & 0 deletions helm/supabase-operator/templates/dashboard-service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: {{ printf "%s-dashboard" (include "supabase-operator.fullname" .) }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Truncate the suffixed dashboard Service name

When the generated fullname exceeds 53 characters, appending -dashboard produces a Service name longer than Kubernetes' 63-character limit because the fullname helper truncates only before this suffix is added. For example, an installation using a long fullnameOverride and metricsService.enabled=false previously rendered valid resources but is now rejected for this Service; truncate the final suffixed name as the webhook service helper does.

Useful? React with 👍 / 👎.

labels:
{{- include "supabase-operator.labels" . | nindent 4 }}
{{- with .Values.dashboard.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ports:
- name: http
port: {{ .Values.dashboard.port }}
protocol: TCP
targetPort: dashboard
selector:
{{- include "supabase-operator.selectorLabels" . | nindent 4 }}
8 changes: 6 additions & 2 deletions helm/supabase-operator/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ spec:
{{- else }}
- --metrics-bind-address=0
{{- end }}
- --dashboard-bind-address=:{{ .Values.dashboard.port }}
{{- range .Values.extraArgs }}
- {{ . | quote }}
{{- end }}
Expand All @@ -56,12 +57,15 @@ spec:
{{- tpl (toYaml .) $ | nindent 12 }}
{{- end }}
{{- end }}
{{- if .Values.webhook.enabled }}
ports:
- containerPort: {{ .Values.dashboard.port }}
name: dashboard
protocol: TCP
{{- if .Values.webhook.enabled }}
- containerPort: {{ .Values.webhook.targetPort }}
name: webhook-server
protocol: TCP
{{- end }}
{{- end }}
livenessProbe:
httpGet:
path: /healthz
Expand Down
4 changes: 4 additions & 0 deletions helm/supabase-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ metricsService:
annotations: {}
port: 8443

dashboard:
annotations: {}
port: 8080

priorityClassName: ""
extraArgs: []
extraEnv: []
Expand Down
123 changes: 123 additions & 0 deletions internal/dashboard/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package dashboard

import (
"encoding/json"
"net/http"
"sort"

"sigs.k8s.io/controller-runtime/pkg/client"

supabasev1alpha1 "github.com/strrl/supabase-operator/api/v1alpha1"
)

type projectListResponse struct {
Projects []projectResponse `json:"projects"`
}

type projectResponse struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
ProjectID string `json:"projectId"`
Phase string `json:"phase"`
Message string `json:"message"`
ReadyComponents int `json:"readyComponents"`
TotalComponents int `json:"totalComponents"`
Components []componentResponse `json:"components"`
UpdatedAt string `json:"updatedAt,omitempty"`
}

type componentResponse struct {
Name string `json:"name"`
Phase string `json:"phase"`
Ready bool `json:"ready"`
ReadyReplicas int32 `json:"readyReplicas"`
Replicas int32 `json:"replicas"`
}

type handler struct {
reader client.Reader
}

func NewHandler(reader client.Reader) http.Handler {
dashboardHandler := &handler{reader: reader}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/projects", dashboardHandler.listProjects)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authenticate cluster-wide project listings

In multi-tenant clusters without restrictive NetworkPolicies, any pod that reaches the new dashboard ClusterIP can call this unauthenticated handler. Because it uses the operator's cluster-wide APIReader, namespace-restricted tenants can bypass Kubernetes RBAC to enumerate project names, IDs, namespaces, and status across the cluster, and can trigger unbounded direct API-server list requests; require authentication and authorization or limit results to the caller's authorized scope.

Useful? React with 👍 / 👎.

mux.HandleFunc("GET /", dashboardHandler.index)
return mux
}

func (h *handler) listProjects(responseWriter http.ResponseWriter, request *http.Request) {
projects := &supabasev1alpha1.SupabaseProjectList{}
if err := h.reader.List(request.Context(), projects); err != nil {
http.Error(responseWriter, "failed to list projects", http.StatusInternalServerError)
return
}

response := projectListResponse{
Projects: make([]projectResponse, 0, len(projects.Items)),
}
for index := range projects.Items {
response.Projects = append(response.Projects, newProjectResponse(&projects.Items[index]))
}
sort.Slice(response.Projects, func(left, right int) bool {
leftName := response.Projects[left].Namespace + "/" + response.Projects[left].Name
rightName := response.Projects[right].Namespace + "/" + response.Projects[right].Name
return leftName < rightName
})

responseWriter.Header().Set("Cache-Control", "no-store")
responseWriter.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(responseWriter).Encode(response); err != nil {
return
}
}

func (h *handler) index(responseWriter http.ResponseWriter, _ *http.Request) {
responseWriter.Header().Set("Cache-Control", "no-cache")
responseWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = responseWriter.Write(indexHTML)
}

func newProjectResponse(project *supabasev1alpha1.SupabaseProject) projectResponse {
components := []componentResponse{
newComponentResponse("Kong", project.Status.Components.Kong),
newComponentResponse("Auth", project.Status.Components.Auth),
newComponentResponse("Realtime", project.Status.Components.Realtime),
newComponentResponse("PostgREST", project.Status.Components.PostgREST),
newComponentResponse("Storage", project.Status.Components.StorageAPI),
newComponentResponse("Meta", project.Status.Components.Meta),
newComponentResponse("Studio", project.Status.Components.Studio),
}

readyComponents := 0
for _, component := range components {
if component.Ready {
readyComponents++
}
}

response := projectResponse{
Namespace: project.Namespace,
Name: project.Name,
ProjectID: project.Spec.ProjectID,
Phase: project.Status.Phase,
Message: project.Status.Message,
ReadyComponents: readyComponents,
TotalComponents: len(components),
Components: components,
}
if project.Status.LastReconcileTime != nil {
response.UpdatedAt = project.Status.LastReconcileTime.UTC().Format("2006-01-02T15:04:05Z")
}
return response
}

func newComponentResponse(name string, component supabasev1alpha1.ComponentStatus) componentResponse {
return componentResponse{
Name: name,
Phase: component.Phase,
Ready: component.Ready,
ReadyReplicas: component.ReadyReplicas,
Replicas: component.Replicas,
}
}
6 changes: 6 additions & 0 deletions internal/dashboard/web.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dashboard

import _ "embed"

//go:embed web/index.html
var indexHTML []byte
Loading
Loading