diff --git a/.env.example b/.env.example index aae7ec6..0d46c2e 100644 --- a/.env.example +++ b/.env.example @@ -32,8 +32,7 @@ GITEA_ADMIN_PASS=helios123 # ----------------------------------------------------------------------------- DOCKER_SERVER=https://index.docker.io/v1/ DOCKER_USERNAME= -DOCKER_PASSWORD= -DOCKER_EMAIL= +DOCKER_TOKEN= # ----------------------------------------------------------------------------- # Git Author (optional, used by operator for GitOps commits) diff --git a/.gitignore b/.gitignore index e705953..410bbe6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ apps/portal/packages/backend/*.sqlite .claude/ .idea + +# mise +mise.toml \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index ae9338c..cf2fb1c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -65,6 +65,7 @@ tasks: deps: [check:env] cmds: - task: setup:cluster + - task: setup:coredns - task: setup:tekton - task: setup:tekton-pruner - task: setup:argocd @@ -93,6 +94,21 @@ tasks: - kubectl config set-cluster k3d-{{.CLUSTER_NAME}} --server=https://localhost:6550 - kubectl cluster-info + setup:coredns: + desc: Patch CoreDNS to use robust external DNS servers instead of k3d node's resolv.conf + cmds: + - echo "Patching CoreDNS to use 8.8.8.8 and 1.1.1.1..." + - cmd: | + kubectl get configmap coredns -n kube-system -o yaml > coredns.yaml + sed 's/forward . \/etc\/resolv.conf/forward . 8.8.8.8 1.1.1.1/' coredns.yaml > coredns-patched.yaml + kubectl apply -f coredns-patched.yaml + rm coredns.yaml coredns-patched.yaml + platforms: [linux, darwin] + - cmd: | + powershell -ExecutionPolicy Bypass -Command "$coredns = kubectl get configmap coredns -n kube-system -o yaml; $coredns = $coredns -replace 'forward \. /etc/resolv\.conf', 'forward . 8.8.8.8 1.1.1.1'; $coredns | Out-File -Encoding utf8 coredns.yaml; kubectl apply -f coredns.yaml; Remove-Item coredns.yaml" + platforms: [windows] + - kubectl rollout restart -n kube-system deployment/coredns + setup:tekton: desc: Install Tekton Pipeline, Triggers, and Interceptors status: @@ -217,6 +233,8 @@ tasks: --set postgresql-ha.enabled=false --set redis-cluster.enabled=false --set redis.enabled=false + --set valkey-cluster.enabled=false + --set valkey.enabled=false --set gitea.config.database.DB_TYPE=sqlite3 --set gitea.config.session.PROVIDER=memory --set gitea.config.cache.ADAPTER=memory @@ -227,15 +245,15 @@ tasks: --set persistence.size=1Gi --set resources.requests.memory=128Mi --set resources.limits.memory=256Mi - platforms: [linux, darwin] - echo "Waiting for Gitea deployment..." - kubectl rollout status deployment/gitea -n gitea --timeout=300s setup:gitea-token: desc: Create Gitea API token, default org, and write to .env cmds: - - echo "Port-forwarding Gitea for setup..." + - echo "Configuring Gitea (Cross-platform)..." - cmd: | + echo "Port-forwarding Gitea for setup..." kubectl port-forward -n gitea svc/gitea-http {{.GITEA_PORT}}:3000 & PF_PID=$! sleep 3 @@ -294,6 +312,8 @@ tasks: kill $PF_PID 2>/dev/null || true platforms: [linux, darwin] + - cmd: powershell -ExecutionPolicy Bypass -File scripts/config-gitea.ps1 -GiteaPort {{.GITEA_PORT}} + platforms: [windows] setup:argocd-gitea: desc: Register Gitea repository credentials with ArgoCD @@ -318,6 +338,8 @@ tasks: password: "$GITEA_ADMIN_PASS" EOF platforms: [linux, darwin] + - cmd: powershell -ExecutionPolicy Bypass -File scripts/setup-argocd-creds.ps1 -GiteaInternalHost {{.GITEA_INTERNAL_HOST}} + platforms: [windows] - echo "ArgoCD can now access Gitea repositories." setup:gitea-gitops-secret: @@ -353,9 +375,10 @@ tasks: for f in "$ENV_FILE" "$PORTAL_ENV"; do update_env_var "$f" "GITOPS_SECRET_REF" "$GITOPS_SECRET_NAME" done - - echo "Created secret '$GITOPS_SECRET_NAME' and wrote GITOPS_SECRET_REF to .env files." platforms: [linux, darwin] + - cmd: powershell -ExecutionPolicy Bypass -File scripts/setup-gitops-creds.ps1 -GiteaInternalHost {{.GITEA_INTERNAL_HOST}} -SecretName {{.GITOPS_SECRET_NAME}} + platforms: [windows] + - echo "Created secret '{{.GITOPS_SECRET_NAME}}' and wrote GITOPS_SECRET_REF to .env files." setup:crds: desc: Install Helios CRDs into the cluster @@ -391,25 +414,21 @@ tasks: desc: Create Docker registry secret and link to pipeline SA cmds: - cmd: | - if [ -z "$DOCKER_USERNAME" ] || [ -z "$DOCKER_PASSWORD" ]; then - echo "DOCKER_USERNAME and DOCKER_PASSWORD must be set in .env" >&2 + if [ -z "$DOCKER_USERNAME" ] || { [ -z "$DOCKER_TOKEN" ] && [ -z "$DOCKER_PASSWORD" ]; }; then + echo "DOCKER_USERNAME and either DOCKER_TOKEN or DOCKER_PASSWORD must be set in .env" >&2 exit 1 fi platforms: [linux, darwin] - cmd: | - powershell -Command "if ([string]::IsNullOrEmpty($env:DOCKER_USERNAME) -or [string]::IsNullOrEmpty($env:DOCKER_PASSWORD)) { Write-Error 'DOCKER_USERNAME and DOCKER_PASSWORD must be set in .env'; exit 1 }" - platforms: [windows] - - cmd: | + DOCKER_SERVER="${DOCKER_SERVER:-https://index.docker.io/v1/}" + DOCKER_SECRET_PASS="${DOCKER_TOKEN:-$DOCKER_PASSWORD}" + kubectl create secret docker-registry docker-credentials \ - --docker-server=${DOCKER_SERVER:-https://index.docker.io/v1/} \ - --docker-username=$DOCKER_USERNAME \ - --docker-password=$DOCKER_PASSWORD \ - --docker-email=${DOCKER_EMAIL:-dev@helios.io} \ + --docker-server="$DOCKER_SERVER" \ + --docker-username="$DOCKER_USERNAME" \ + --docker-password="$DOCKER_SECRET_PASS" \ --dry-run=client -o yaml | kubectl apply -f - platforms: [linux, darwin] - - cmd: | - powershell -Command "$server = if ([string]::IsNullOrEmpty($env:DOCKER_SERVER)) { 'https://index.docker.io/v1/' } else { $env:DOCKER_SERVER }; $email = if ([string]::IsNullOrEmpty($env:DOCKER_EMAIL)) { 'dev@helios.io' } else { $env:DOCKER_EMAIL }; kubectl create secret docker-registry docker-credentials --docker-server=$server --docker-username=$env:DOCKER_USERNAME --docker-password=$env:DOCKER_PASSWORD --docker-email=$email --dry-run=client -o yaml | kubectl apply -f -" - platforms: [windows] - cmd: | if kubectl get sa pipeline >/dev/null 2>&1; then kubectl patch sa pipeline -p '{"secrets": [{"name": "docker-credentials"}]}' @@ -417,8 +436,7 @@ tasks: echo "pipeline ServiceAccount not found yet; skipping patch (will be created by Tekton)" fi platforms: [linux, darwin] - - cmd: | - powershell -Command "kubectl get sa pipeline *> $null; if ($LASTEXITCODE -eq 0) { kubectl patch sa pipeline -p '{\"secrets\": [{\"name\": \"docker-credentials\"}]}' } else { Write-Host 'pipeline ServiceAccount not found yet; skipping patch (will be created by Tekton)' }" + - cmd: powershell -ExecutionPolicy Bypass -File scripts/setup-credentials.ps1 platforms: [windows] setup:portal-deps: @@ -457,8 +475,9 @@ tasks: dev:portal: desc: Run the Backstage portal with ArgoCD + kubectl proxy + dir: apps/portal cmds: - - cmd: cd apps/portal && ./start-dev.sh + - cmd: ./start-dev.sh platforms: [linux, darwin] - cmd: powershell -ExecutionPolicy Bypass -File ../../scripts/start-portal.ps1 -ArgocdPort {{.ARGOCD_PORT}} platforms: [windows] diff --git a/apps/operator/.github/workflows/lint.yml b/apps/operator/.github/workflows/lint.yml index b366484..05e383b 100644 --- a/apps/operator/.github/workflows/lint.yml +++ b/apps/operator/.github/workflows/lint.yml @@ -20,4 +20,4 @@ jobs: - name: Run linter uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 with: - version: v2.11.3 + version: v2.11.4 diff --git a/apps/operator/Makefile b/apps/operator/Makefile index d526f27..331cfe1 100644 --- a/apps/operator/Makefile +++ b/apps/operator/Makefile @@ -242,7 +242,7 @@ CONTROLLER_TOOLS_VERSION ?= v0.20.1 ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}') #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') -GOLANGCI_LINT_VERSION ?= v2.11.3 +GOLANGCI_LINT_VERSION ?= v2.11.4 .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. diff --git a/apps/operator/api/v1alpha1/heliosapp_types.go b/apps/operator/api/v1alpha1/heliosapp_types.go index 8ce1cbb..844d727 100644 --- a/apps/operator/api/v1alpha1/heliosapp_types.go +++ b/apps/operator/api/v1alpha1/heliosapp_types.go @@ -62,6 +62,12 @@ type HeliosAppSpec struct { // +kubebuilder:default="from-code-to-cluster" PipelineName string `json:"pipelineName,omitempty"` + // TriggerType is the type of trigger to use for the pipeline + // +optional + // +kubebuilder:validation:Enum=gitea-push;db-migrate + // +kubebuilder:default="gitea-push" + TriggerType string `json:"triggerType,omitempty"` + // WebhookSecret is the name of the secret containing the Gitea webhook secret token // +optional // +kubebuilder:default="gitea-webhook-secret" @@ -100,6 +106,10 @@ type HeliosAppSpec struct { // +optional TestCommand string `json:"testCommand,omitempty"` + // TestImage is the container image used to execute testCommand (e.g. "node:24") + // +optional + TestImage string `json:"testImage,omitempty"` + // Env variables for the application // +optional Env []corev1.EnvVar `json:"env,omitempty"` @@ -120,6 +130,11 @@ type HeliosAppSpec struct { // +optional ContextSubpath string `json:"contextSubpath,omitempty"` + // DatabaseSecretRef is the name of the secret containing database credentials for migrations. + // Defaults to {appName}-db-secret if not set. + // +optional + DatabaseSecretRef string `json:"databaseSecretRef,omitempty"` + // Components define the workloads of the application Components []Component `json:"components"` } diff --git a/apps/operator/config/crd/bases/app.helios.io_heliosapps.yaml b/apps/operator/config/crd/bases/app.helios.io_heliosapps.yaml index a95a929..c620b1c 100644 --- a/apps/operator/config/crd/bases/app.helios.io_heliosapps.yaml +++ b/apps/operator/config/crd/bases/app.helios.io_heliosapps.yaml @@ -100,6 +100,11 @@ spec: contextSubpath: description: ContextSubpath is the path where the Dockerfile is located type: string + databaseSecretRef: + default: api-db-secret + description: DatabaseSecretRef is the name of the secret containing + database credentials for migrations + type: string description: description: Description of the application type: string @@ -370,6 +375,17 @@ spec: testCommand: description: TestCommand is the command to run tests (e.g. "npm test") type: string + testImage: + description: TestImage is the container image used to execute testCommand + (e.g. "node:24") + type: string + triggerType: + default: gitea-push + description: TriggerType is the type of trigger to use for the pipeline + enum: + - gitea-push + - db-migrate + type: string webhookDomain: description: WebhookDomain is the external domain (e.g., ngrok) for Git webhooks diff --git a/apps/operator/internal/controller/database/injection.go b/apps/operator/internal/controller/database/injection.go index e6a2993..7a9dd50 100644 --- a/apps/operator/internal/controller/database/injection.go +++ b/apps/operator/internal/controller/database/injection.go @@ -2,30 +2,55 @@ package database import ( "strconv" + "strings" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" ) -const dbPortEnvName = "DB_PORT" +const ( + dbPortEnvName = "DB_PORT" + dbNameEnvName = "DB_NAME" + databaseURLEnvName = "DATABASE_URL" + // dbTypePostgres is the canonical DB type identifier used across the database package. + dbTypePostgres = "postgres" + // postgresDatabaseURLTemplate uses Kubernetes $(VAR) expansion from earlier env entries. + postgresDatabaseURLTemplate = "postgres://$(DB_USER):$(DB_PASS)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)" +) + +// connectionURLTemplateForDBType returns a DATABASE_URL value for the given engine type. +// Only types with a defined URL layout are supported; others return ok=false. +func connectionURLTemplateForDBType(dbType string) (template string, ok bool) { + switch strings.ToLower(strings.TrimSpace(dbType)) { + case dbTypePostgres, "postgresql": + return postgresDatabaseURLTemplate, true + default: + return "", false + } +} // databaseSecretEnvVarNames lists env vars resolved from Secret keys. -var databaseSecretEnvVarNames = []string{"DB_HOST", "DB_USER", "DB_PASS"} +// These are injected as environment variable references into application containers. +var databaseSecretEnvVarNames = []string{"DB_HOST", "DB_USER", "DB_PASS", "PGRST_DB_URI"} // InjectDatabaseEnvVars patches a Deployment's first container to include // DB_HOST, DB_USER, DB_PASS env vars referencing the given K8s Secret. // The function is idempotent. -func InjectDatabaseEnvVars(deploy *appsv1.Deployment, secretName string) bool { - changed, _ := InjectDatabaseEnvVarsForContainer(deploy, secretName, "", DefaultPostgresPort) +// dbName is the logical database name (e.g. from the database trait); when empty, +// DB_NAME is not injected. +// dbType selects whether DATABASE_URL is set (only types with a known URL template, e.g. postgres). +func InjectDatabaseEnvVars(deploy *appsv1.Deployment, secretName, dbName, dbType string) bool { + changed, _ := InjectDatabaseEnvVarsForContainer(deploy, secretName, "", DefaultPostgresPort, dbName, dbType) return changed } // InjectDatabaseEnvVarsForContainer patches a Deployment container to include // DB_HOST, DB_USER, DB_PASS env vars referencing the given K8s Secret, plus a -// literal DB_PORT value. +// literal DB_PORT value. When dbName is non-empty, it sets DB_NAME. DATABASE_URL +// is added only when dbType has a defined connection string template (see connectionURLTemplateForDBType). // If preferredContainerName is not found, it falls back to the first container. // Returns (changed, exactMatch). -func InjectDatabaseEnvVarsForContainer(deploy *appsv1.Deployment, secretName, preferredContainerName string, dbPort int32) (bool, bool) { +func InjectDatabaseEnvVarsForContainer(deploy *appsv1.Deployment, secretName, preferredContainerName string, dbPort int32, dbName, dbType string) (bool, bool) { if len(deploy.Spec.Template.Spec.Containers) == 0 { return false, false } @@ -99,9 +124,39 @@ func InjectDatabaseEnvVarsForContainer(deploy *appsv1.Deployment, secretName, pr changed = true } + if dbName != "" { + if ensureLiteralEnvVar(container, dbNameEnvName, dbName) { + changed = true + } + if urlTpl, ok := connectionURLTemplateForDBType(dbType); ok { + if ensureLiteralEnvVar(container, databaseURLEnvName, urlTpl) { + changed = true + } + } + } + return changed, exactMatch } +func ensureLiteralEnvVar(container *corev1.Container, name, value string) bool { + for i := range container.Env { + if container.Env[i].Name != name { + continue + } + if container.Env[i].Value == value && container.Env[i].ValueFrom == nil { + return false + } + container.Env[i].Value = value + container.Env[i].ValueFrom = nil + return true + } + container.Env = append(container.Env, corev1.EnvVar{ + Name: name, + Value: value, + }) + return true +} + func selectTargetContainerIndex(containers []corev1.Container, preferredContainerName string) (int, bool) { if len(containers) == 0 { return -1, false diff --git a/apps/operator/internal/controller/database/injection_test.go b/apps/operator/internal/controller/database/injection_test.go index 9393add..de94cac 100644 --- a/apps/operator/internal/controller/database/injection_test.go +++ b/apps/operator/internal/controller/database/injection_test.go @@ -7,6 +7,37 @@ import ( corev1 "k8s.io/api/core/v1" ) +func TestConnectionURLTemplateForDBType(t *testing.T) { + t.Parallel() + tests := []struct { + dbType string + ok bool + }{ + {"postgres", true}, + {"POSTGRES", true}, + {"postgresql", true}, + {" PostgreSQL ", true}, + {"mysql", false}, + {"", false}, + } + for _, tt := range tests { + name := tt.dbType + if name == "" { + name = "empty" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + got, ok := connectionURLTemplateForDBType(tt.dbType) + if ok != tt.ok { + t.Fatalf("ok: got %v, want %v (template %q)", ok, tt.ok, got) + } + if tt.ok && got != postgresDatabaseURLTemplate { + t.Fatalf("template: got %q, want %q", got, postgresDatabaseURLTemplate) + } + }) + } +} + func TestInjectDatabaseEnvVars(t *testing.T) { t.Run("InjectsAllEnvVars", func(t *testing.T) { deploy := &appsv1.Deployment{ @@ -27,20 +58,21 @@ func TestInjectDatabaseEnvVars(t *testing.T) { }, } - changed := InjectDatabaseEnvVars(deploy, "api-server-db-secret") + changed := InjectDatabaseEnvVars(deploy, "api-server-db-secret", "api-server-db", "postgres") if !changed { t.Fatal("Expected InjectDatabaseEnvVars to return true (changed)") } container := deploy.Spec.Template.Spec.Containers[0] - if len(container.Env) != 5 { - t.Fatalf("Expected 5 env vars, got %d", len(container.Env)) + if len(container.Env) != 8 { + t.Fatalf("Expected 8 env vars, got %d", len(container.Env)) } expectedEnvs := map[string]string{ - "DB_HOST": "DB_HOST", - "DB_USER": "DB_USER", - "DB_PASS": "DB_PASS", + "DB_HOST": "DB_HOST", + "DB_USER": "DB_USER", + "DB_PASS": "DB_PASS", + "PGRST_DB_URI": "PGRST_DB_URI", } foundDBPort := false for _, env := range container.Env { @@ -68,6 +100,16 @@ func TestInjectDatabaseEnvVars(t *testing.T) { } foundDBPort = true } + if env.Name == "DB_NAME" { + if env.Value != "api-server-db" || env.ValueFrom != nil { + t.Errorf("Expected DB_NAME literal api-server-db, got %+v", env) + } + } + if env.Name == "DATABASE_URL" { + if env.Value != postgresDatabaseURLTemplate || env.ValueFrom != nil { + t.Errorf("Expected DATABASE_URL from template, got %+v", env) + } + } } if len(expectedEnvs) > 0 { t.Errorf("Missing expected env vars: %v", expectedEnvs) @@ -77,6 +119,44 @@ func TestInjectDatabaseEnvVars(t *testing.T) { } }) + t.Run("OmitsDatabaseURLWhenDBTypeUnsupported", func(t *testing.T) { + deploy := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "api-server", + Image: "myregistry/api:v1", + Env: []corev1.EnvVar{ + {Name: "PORT", Value: "3000"}, + }, + }, + }, + }, + }, + }, + } + + changed := InjectDatabaseEnvVars(deploy, "api-server-db-secret", "api-server-db", "mysql") + if !changed { + t.Fatal("Expected InjectDatabaseEnvVars to return true (changed)") + } + + container := deploy.Spec.Template.Spec.Containers[0] + if len(container.Env) != 7 { + t.Fatalf("Expected 7 env vars (no DATABASE_URL), got %d", len(container.Env)) + } + for _, env := range container.Env { + if env.Name == "DATABASE_URL" { + t.Fatalf("unexpected DATABASE_URL for dbType mysql: %+v", env) + } + if env.Name == "DB_NAME" && env.Value != "api-server-db" { + t.Errorf("DB_NAME: expected api-server-db, got %q", env.Value) + } + } + }) + t.Run("Idempotent", func(t *testing.T) { deploy := &appsv1.Deployment{ Spec: appsv1.DeploymentSpec{ @@ -90,13 +170,13 @@ func TestInjectDatabaseEnvVars(t *testing.T) { }, } - changed := InjectDatabaseEnvVars(deploy, "api-server-db-secret") + changed := InjectDatabaseEnvVars(deploy, "api-server-db-secret", "api-server-db", "postgres") if !changed { t.Fatal("Expected first injection to report changes") } firstCount := len(deploy.Spec.Template.Spec.Containers[0].Env) - changed = InjectDatabaseEnvVars(deploy, "api-server-db-secret") + changed = InjectDatabaseEnvVars(deploy, "api-server-db-secret", "api-server-db", "postgres") if changed { t.Error("Expected second injection to report no changes (idempotent)") } @@ -132,14 +212,14 @@ func TestInjectDatabaseEnvVars(t *testing.T) { }, } - changed := InjectDatabaseEnvVars(deploy, "api-server-db-secret") + changed := InjectDatabaseEnvVars(deploy, "api-server-db-secret", "api-server-db", "postgres") if !changed { t.Fatal("Expected InjectDatabaseEnvVars to return true when existing env vars have wrong source") } container := deploy.Spec.Template.Spec.Containers[0] - if len(container.Env) != 5 { - t.Fatalf("Expected 5 env vars, got %d", len(container.Env)) + if len(container.Env) != 8 { + t.Fatalf("Expected 8 env vars, got %d", len(container.Env)) } for _, env := range container.Env { @@ -176,7 +256,7 @@ func TestInjectDatabaseEnvVars(t *testing.T) { }, } - changed := InjectDatabaseEnvVars(deploy, "test-secret") + changed := InjectDatabaseEnvVars(deploy, "test-secret", "", "") if changed { t.Error("Expected no changes for Deployment with no containers") } @@ -196,7 +276,7 @@ func TestInjectDatabaseEnvVars(t *testing.T) { }, } - changed, exactMatch := InjectDatabaseEnvVarsForContainer(deploy, "api-server-db-secret", "api-server", 5433) + changed, exactMatch := InjectDatabaseEnvVarsForContainer(deploy, "api-server-db-secret", "api-server", 5433, "api-server-db", "postgres") if !changed { t.Fatal("Expected env injection changes") } @@ -210,13 +290,22 @@ func TestInjectDatabaseEnvVars(t *testing.T) { } appContainer := deploy.Spec.Template.Spec.Containers[1] - expected := map[string]bool{"DB_HOST": false, "DB_USER": false, "DB_PASS": false, "DB_PORT": false} + expected := map[string]bool{ + "DB_HOST": false, "DB_USER": false, "DB_PASS": false, "PGRST_DB_URI": false, "DB_PORT": false, + "DB_NAME": false, "DATABASE_URL": false, + } for _, env := range appContainer.Env { if _, ok := expected[env.Name]; ok { expected[env.Name] = true if env.Name == "DB_PORT" && env.Value != "5433" { t.Errorf("Expected DB_PORT=5433, got %q", env.Value) } + if env.Name == "DB_NAME" && env.Value != "api-server-db" { + t.Errorf("Expected DB_NAME=api-server-db, got %q", env.Value) + } + if env.Name == "DATABASE_URL" && env.Value != postgresDatabaseURLTemplate { + t.Errorf("Expected DATABASE_URL template, got %q", env.Value) + } } } for name, found := range expected { @@ -239,15 +328,15 @@ func TestInjectDatabaseEnvVars(t *testing.T) { }, } - changed, exactMatch := InjectDatabaseEnvVarsForContainer(deploy, "app-db-secret", "missing-app", 5432) + changed, exactMatch := InjectDatabaseEnvVarsForContainer(deploy, "app-db-secret", "missing-app", 5432, "only-container-db", "postgres") if !changed { t.Fatal("Expected env injection changes") } if exactMatch { t.Fatal("Expected fallback because preferred container does not exist") } - if len(deploy.Spec.Template.Spec.Containers[0].Env) != 4 { - t.Fatalf("Expected 4 injected DB env vars, got %d", len(deploy.Spec.Template.Spec.Containers[0].Env)) + if len(deploy.Spec.Template.Spec.Containers[0].Env) != 7 { + t.Fatalf("Expected 7 injected DB env vars, got %d", len(deploy.Spec.Template.Spec.Containers[0].Env)) } }) @@ -264,7 +353,7 @@ func TestInjectDatabaseEnvVars(t *testing.T) { }, } - changed, exactMatch := InjectDatabaseEnvVarsForContainer(deploy, "app-db-secret", "", 5432) + changed, exactMatch := InjectDatabaseEnvVarsForContainer(deploy, "app-db-secret", "", 5432, "app-db", "postgres") if !changed { t.Fatal("Expected env injection changes") } diff --git a/apps/operator/internal/controller/database/reconciler.go b/apps/operator/internal/controller/database/reconciler.go index ddfd897..a091406 100644 --- a/apps/operator/internal/controller/database/reconciler.go +++ b/apps/operator/internal/controller/database/reconciler.go @@ -42,7 +42,7 @@ func (r *Reconciler) ReconcileSecrets(ctx context.Context, app *appv1alpha1.Heli } for _, dbTrait := range dbTraits { - if strings.ToLower(dbTrait.Properties.DBType) != "postgres" { + if strings.ToLower(dbTrait.Properties.DBType) != dbTypePostgres { log.V(1).Info("Skipping credential secret creation for non-postgres database type", "component", dbTrait.ComponentName, "dbType", dbTrait.Properties.DBType) @@ -90,7 +90,18 @@ func (r *Reconciler) ReconcileSecrets(ctx context.Context, app *appv1alpha1.Heli return fmt.Errorf("failed to generate credentials for %s: %w", dbTrait.ComponentName, err) } - secret := GenerateDatabaseSecret(app.Namespace, secretName, dbTrait.ComponentName, creds, dbHost) + // Compute effective database name and port + effectiveDBName := dbTrait.Properties.DBName + if effectiveDBName == "" { + effectiveDBName = fmt.Sprintf("%s-db", dbTrait.ComponentName) + } + + effectivePort := dbTrait.Properties.Port + if effectivePort <= 0 { + effectivePort = DefaultPostgresPort + } + + secret := GenerateDatabaseSecret(app.Namespace, secretName, dbTrait.ComponentName, creds, dbHost, effectiveDBName, int32(effectivePort)) if err := ctrl.SetControllerReference(app, secret, r.Scheme); err != nil { log.Error(err, "Failed to set owner reference for database secret", @@ -115,7 +126,9 @@ func (r *Reconciler) ReconcileSecrets(ctx context.Context, app *appv1alpha1.Heli log.Info("Successfully created database secret", "component", dbTrait.ComponentName, "secret", secretName, - "dbHost", dbHost) + "dbHost", dbHost, + "effectiveDBName", effectiveDBName, + "effectivePort", effectivePort) } return nil @@ -133,7 +146,7 @@ func (r *Reconciler) ReconcileInstances(ctx context.Context, app *appv1alpha1.He } for _, dbTrait := range dbTraits { - if strings.ToLower(dbTrait.Properties.DBType) != "postgres" { + if strings.ToLower(dbTrait.Properties.DBType) != dbTypePostgres { log.V(1).Info("Skipping non-postgres database type", "component", dbTrait.ComponentName, "dbType", dbTrait.Properties.DBType) @@ -143,10 +156,7 @@ func (r *Reconciler) ReconcileInstances(ctx context.Context, app *appv1alpha1.He dbHost := GetDatabaseHost(dbTrait.ComponentName) secretName := GetDatabaseSecretName(dbTrait.ComponentName) - effectiveDBName := dbTrait.Properties.DBName - if effectiveDBName == "" { - effectiveDBName = fmt.Sprintf("%s-db", dbTrait.ComponentName) - } + effectiveDBName := EffectiveDatabaseName(dbTrait) version := dbTrait.Properties.Version if version == "" { @@ -298,7 +308,7 @@ func (r *Reconciler) ReconcileInjection(ctx context.Context, app *appv1alpha1.He pendingInjection := false for _, dbTrait := range dbTraits { - if strings.ToLower(dbTrait.Properties.DBType) != "postgres" { + if strings.ToLower(dbTrait.Properties.DBType) != dbTypePostgres { log.V(1).Info("Skipping env var injection for non-postgres database type", "component", dbTrait.ComponentName, "dbType", dbTrait.Properties.DBType) @@ -329,7 +339,8 @@ func (r *Reconciler) ReconcileInjection(ctx context.Context, app *appv1alpha1.He port = DefaultPostgresPort } - changed, exactContainerMatch := InjectDatabaseEnvVarsForContainer(deploy, secretName, dbTrait.ComponentName, int32(port)) + dbName := EffectiveDatabaseName(dbTrait) + changed, exactContainerMatch := InjectDatabaseEnvVarsForContainer(deploy, secretName, dbTrait.ComponentName, int32(port), dbName, dbTrait.Properties.DBType) if !exactContainerMatch { fallbackContainer := "" if len(deploy.Spec.Template.Spec.Containers) > 0 { diff --git a/apps/operator/internal/controller/database/reconciler_test.go b/apps/operator/internal/controller/database/reconciler_test.go index 5dd7f25..3e01a80 100644 --- a/apps/operator/internal/controller/database/reconciler_test.go +++ b/apps/operator/internal/controller/database/reconciler_test.go @@ -46,7 +46,7 @@ func TestReconcileSecrets(t *testing.T) { dbProps := map[string]any{ "dbType": "postgres", "dbName": "mydb", - "version": "16", + "version": "18.3", } dbPropsJSON, _ := json.Marshal(dbProps) @@ -193,7 +193,7 @@ func TestReconcileInstances(t *testing.T) { dbProps := map[string]any{ "dbType": "postgres", "dbName": "my_custom_db", - "version": "16", + "version": "18.3", "storage": "2Gi", } dbPropsJSON, _ := json.Marshal(dbProps) @@ -348,7 +348,7 @@ func TestReconcileInstances(t *testing.T) { Replicas: func() *int32 { r := int32(1); return &r }(), Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ - Containers: []corev1.Container{{Name: "postgres", Image: "postgres:15"}}, + Containers: []corev1.Container{{Name: "postgres", Image: "postgres:18.3"}}, }, }, VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{ @@ -379,8 +379,8 @@ func TestReconcileInstances(t *testing.T) { if err != nil { t.Fatalf("failed to get updated StatefulSet: %v", err) } - if got := updatedSts.Spec.Template.Spec.Containers[0].Image; got != "postgres:16" { - t.Fatalf("expected image postgres:16, got %s", got) + if got := updatedSts.Spec.Template.Spec.Containers[0].Image; got != "postgres:18.3" { + t.Fatalf("expected image postgres:18.3, got %s", got) } updatedSvc := &corev1.Service{} @@ -425,7 +425,7 @@ func TestReconcileInstances(t *testing.T) { } func TestReconcileInjection(t *testing.T) { - dbProps := map[string]any{"dbType": "postgres", "dbName": "mydb", "version": "16"} + dbProps := map[string]any{"dbType": "postgres", "dbName": "mydb", "version": "18.3"} dbPropsJSON, _ := json.Marshal(dbProps) app := &appv1alpha1.HeliosApp{ @@ -481,15 +481,33 @@ func TestReconcileInjection(t *testing.T) { } container := updatedDeploy.Spec.Template.Spec.Containers[0] - expectedEnvNames := map[string]bool{"DB_HOST": false, "DB_USER": false, "DB_PASS": false} + expectedEnvNames := map[string]bool{ + "DB_HOST": false, "DB_USER": false, "DB_PASS": false, + "DB_PORT": false, "DB_NAME": false, "DATABASE_URL": false, + } for _, env := range container.Env { if _, ok := expectedEnvNames[env.Name]; ok { expectedEnvNames[env.Name] = true - if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { - t.Errorf("Env %s should reference a secret", env.Name) - } else if env.ValueFrom.SecretKeyRef.Name != "api-server-db-secret" { - t.Errorf("Env %s: expected secret name %q, got %q", - env.Name, "api-server-db-secret", env.ValueFrom.SecretKeyRef.Name) + switch env.Name { + case "DB_HOST", "DB_USER", "DB_PASS": + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + t.Errorf("Env %s should reference a secret", env.Name) + } else if env.ValueFrom.SecretKeyRef.Name != "api-server-db-secret" { + t.Errorf("Env %s: expected secret name %q, got %q", + env.Name, "api-server-db-secret", env.ValueFrom.SecretKeyRef.Name) + } + case "DB_PORT": + if env.Value != "5432" || env.ValueFrom != nil { + t.Errorf("Env DB_PORT: expected literal 5432, got %+v", env) + } + case "DB_NAME": + if env.Value != "mydb" || env.ValueFrom != nil { + t.Errorf("Env DB_NAME: expected literal mydb, got %+v", env) + } + case "DATABASE_URL": + if env.Value != postgresDatabaseURLTemplate || env.ValueFrom != nil { + t.Errorf("Env DATABASE_URL: expected template value, got %+v", env) + } } } } diff --git a/apps/operator/internal/controller/database/resources.go b/apps/operator/internal/controller/database/resources.go index 021e239..2f4fbfc 100644 --- a/apps/operator/internal/controller/database/resources.go +++ b/apps/operator/internal/controller/database/resources.go @@ -2,6 +2,7 @@ package database import ( "fmt" + "net/url" "strings" appsv1 "k8s.io/api/apps/v1" @@ -13,8 +14,29 @@ import ( var requiredDatabaseSecretKeys = []string{"DB_USER", "DB_PASS", "DB_HOST"} +// formatPostgresURI constructs a PostgreSQL connection URI from components. +// It properly escapes the username and password for use in URLs. +// Format: postgres://username:password@host:port/dbname?sslmode=disable +func formatPostgresURI(username, password, host, dbName string, port int32) string { + // Escape username and password for use in URL + enscodedUser := url.QueryEscape(username) + enscodedPassword := url.QueryEscape(password) + + return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", + enscodedUser, + enscodedPassword, + host, + port, + dbName, + ) +} + // GenerateDatabaseSecret creates a Kubernetes Secret containing database credentials. -func GenerateDatabaseSecret(namespace, secretName, componentName string, creds *DatabaseCredentials, dbHost string) *corev1.Secret { +// Parameters: namespace, secretName, componentName, credentials, dbHost, dbName, port. +func GenerateDatabaseSecret(namespace, secretName, componentName string, creds *DatabaseCredentials, dbHost, dbName string, port int32) *corev1.Secret { + // Compute the PostgreSQL connection URI + pgrstURI := formatPostgresURI(creds.Username, creds.Password, dbHost, dbName, port) + return &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -27,9 +49,10 @@ func GenerateDatabaseSecret(namespace, secretName, componentName string, creds * }, Type: corev1.SecretTypeOpaque, Data: map[string][]byte{ - "DB_USER": []byte(creds.Username), - "DB_PASS": []byte(creds.Password), - "DB_HOST": []byte(dbHost), + "DB_USER": []byte(creds.Username), + "DB_PASS": []byte(creds.Password), + "DB_HOST": []byte(dbHost), + "PGRST_DB_URI": []byte(pgrstURI), }, } } @@ -72,7 +95,7 @@ func GenerateDatabaseStatefulSet(namespace, name, secretName, dbName, version, s "app": name, "helios.io/managed-by": "operator", "helios.io/trait": "database", - "helios.io/db-type": "postgres", + "helios.io/db-type": dbTypePostgres, } probeCommand := `pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" -p "$PGPORT"` @@ -96,12 +119,12 @@ func GenerateDatabaseStatefulSet(namespace, name, secretName, dbName, version, s Spec: corev1.PodSpec{ Containers: []corev1.Container{ { - Name: "postgres", + Name: dbTypePostgres, Image: fmt.Sprintf("postgres:%s", version), Ports: []corev1.ContainerPort{ { ContainerPort: port, - Name: "postgres", + Name: dbTypePostgres, }, }, Env: []corev1.EnvVar{ diff --git a/apps/operator/internal/controller/database/resources_test.go b/apps/operator/internal/controller/database/resources_test.go index c5ed9e9..0bd730a 100644 --- a/apps/operator/internal/controller/database/resources_test.go +++ b/apps/operator/internal/controller/database/resources_test.go @@ -12,18 +12,87 @@ import ( appv1alpha1 "github.com/helios-platform-team/helios-platform/apps/operator/api/v1alpha1" ) +func TestFormatPostgresURI(t *testing.T) { + tests := []struct { + name string + username string + password string + host string + dbName string + port int32 + expected string + }{ + { + name: "basic credentials", + username: "user", + password: "pass", + host: "localhost", + dbName: "mydb", + port: 5432, + expected: "postgres://user:pass@localhost:5432/mydb?sslmode=disable", + }, + { + name: "special chars in password", + username: "user", + password: "p@ss:word/", + host: "db.example.com", + dbName: "app_db", + port: 5432, + expected: "postgres://user:p%40ss%3Aword%2F@db.example.com:5432/app_db?sslmode=disable", + }, + { + name: "special chars in username", + username: "user+admin", + password: "password", + host: "postgres-host", + dbName: "database", + port: 5432, + expected: "postgres://user%2Badmin:password@postgres-host:5432/database?sslmode=disable", + }, + { + name: "custom port", + username: "admin", + password: "secret123", + host: "db-instance", + dbName: "prod_db", + port: 5433, + expected: "postgres://admin:secret123@db-instance:5433/prod_db?sslmode=disable", + }, + { + name: "IPv6 host", + username: "user", + password: "pass", + host: "::1", + dbName: "testdb", + port: 5432, + expected: "postgres://user:pass@::1:5432/testdb?sslmode=disable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatPostgresURI(tt.username, tt.password, tt.host, tt.dbName, tt.port) + if got != tt.expected { + t.Errorf("formatPostgresURI() = %q, want %q", got, tt.expected) + } + }) + } +} + func TestGenerateDatabaseSecret(t *testing.T) { namespace := "test-namespace" secretName := "my-app-db-secret" componentName := "my-app" dbHost := "my-app-db" + dbName := "my-app-db" + port := int32(5432) creds := &DatabaseCredentials{ Username: "testuser", Password: "testpassword123", } - secret := GenerateDatabaseSecret(namespace, secretName, componentName, creds, dbHost) + secret := GenerateDatabaseSecret(namespace, secretName, componentName, creds, dbHost, dbName, port) if secret.Name != secretName { t.Errorf("Expected secret name %q, got %q", secretName, secret.Name) @@ -56,6 +125,12 @@ func TestGenerateDatabaseSecret(t *testing.T) { t.Errorf("Expected DB_HOST %q, got %q", dbHost, string(secret.Data["DB_HOST"])) } + // Check that PGRST_DB_URI is generated and properly escaped + expectedURI := "postgres://testuser:testpassword123@my-app-db:5432/my-app-db?sslmode=disable" + if string(secret.Data["PGRST_DB_URI"]) != expectedURI { + t.Errorf("Expected PGRST_DB_URI %q, got %q", expectedURI, string(secret.Data["PGRST_DB_URI"])) + } + if secret.Type != corev1.SecretTypeOpaque { t.Errorf("Expected secret type %v, got %v", corev1.SecretTypeOpaque, secret.Type) } @@ -105,7 +180,7 @@ func TestExtractDatabaseTraits(t *testing.T) { dbProps := map[string]any{ "dbType": "postgres", "dbName": "mydb", - "version": "16", + "version": "18.3", } dbPropsJSON, _ := json.Marshal(dbProps) @@ -173,7 +248,7 @@ func TestExtractDatabaseTraits(t *testing.T) { func TestGenerateDatabaseStatefulSet(t *testing.T) { sts, err := GenerateDatabaseStatefulSet( "test-ns", "my-app-db", "my-app-db-secret", - "my_custom_db", "16", "2Gi", 5432, + "my_custom_db", "18.3", "2Gi", 5432, ) if err != nil { @@ -208,8 +283,8 @@ func TestGenerateDatabaseStatefulSet(t *testing.T) { } container := containers[0] - if container.Image != "postgres:16" { - t.Errorf("Expected image %q, got %q", "postgres:16", container.Image) + if container.Image != "postgres:18.3" { + t.Errorf("Expected image %q, got %q", "postgres:18.3", container.Image) } if len(container.Ports) != 1 || container.Ports[0].ContainerPort != 5432 { diff --git a/apps/operator/internal/controller/database/traits.go b/apps/operator/internal/controller/database/traits.go index 7fd362d..ca98659 100644 --- a/apps/operator/internal/controller/database/traits.go +++ b/apps/operator/internal/controller/database/traits.go @@ -2,6 +2,7 @@ package database import ( "encoding/json" + "fmt" "strings" appv1alpha1 "github.com/helios-platform-team/helios-platform/apps/operator/api/v1alpha1" @@ -16,7 +17,7 @@ const ( UsernameCharset = "abcdefghijklmnopqrstuvwxyz0123456789" DatabaseTraitType = "database" - DefaultPostgresVersion = "16" + DefaultPostgresVersion = "18.3" DefaultPostgresPort = 5432 DefaultDatabaseStorage = "1Gi" PostgresDataPath = "/var/lib/postgresql/data" @@ -84,6 +85,15 @@ func GetDatabaseHost(componentName string) string { return componentName + "-db" } +// EffectiveDatabaseName returns the logical Postgres database name for a trait, +// matching ReconcileInstances (POSTGRES_DB / connection string). +func EffectiveDatabaseName(tr DatabaseTrait) string { + if tr.Properties.DBName != "" { + return tr.Properties.DBName + } + return fmt.Sprintf("%s-db", tr.ComponentName) +} + func truncateForLog(raw []byte, maxLen int) string { if maxLen <= 0 || len(raw) <= maxLen { return string(raw) diff --git a/apps/operator/internal/controller/heliosapp_controller.go b/apps/operator/internal/controller/heliosapp_controller.go index 7e88349..3e19b18 100644 --- a/apps/operator/internal/controller/heliosapp_controller.go +++ b/apps/operator/internal/controller/heliosapp_controller.go @@ -98,6 +98,13 @@ func (r *HeliosAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( log.Info("Reconciling HeliosApp", "name", heliosApp.Name, "namespace", heliosApp.Namespace) + // Pre-flight validation: Check if all referenced secrets exist + if err := r.validateSecretReferences(ctx, &heliosApp); err != nil { + log.Error(err, "Pre-flight validation failed: referenced secret does not exist") + r.updateStatus(ctx, &heliosApp, appv1alpha1.PhaseFailed, fmt.Sprintf("Configuration error: %v", err)) + return ctrl.Result{}, err + } + // 2. Map CRD to Application Model appModel, err := mapCRDToModel(&heliosApp) if err != nil { @@ -150,7 +157,7 @@ func (r *HeliosAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // ------------------------------------------------------------------ // PHASE 0.9: Inject Database Credentials into Backend Deployment // Patches the live Deployment (deployed by ArgoCD) to add DB_HOST, - // DB_USER, DB_PASS env vars referencing the operator-managed Secret. + // DB_USER, DB_PASS, DB_PORT, DB_NAME, and DATABASE_URL (via $(VAR) expansion). // Runs AFTER secrets and instances so the Secret already exists. // ------------------------------------------------------------------ dbInjectionPending, err := r.Database.ReconcileInjection(ctx, &heliosApp) @@ -240,7 +247,8 @@ func (r *HeliosAppReconciler) findObjectsForSecret(ctx context.Context, obj clie for _, app := range heliosAppList.Items { // Check if this app references the changed secret if app.Spec.GitOpsSecretRef == obj.GetName() || - app.Spec.WebhookSecret == obj.GetName() { + app.Spec.WebhookSecret == obj.GetName() || + app.Spec.DatabaseSecretRef == obj.GetName() { requests = append(requests, reconcile.Request{ NamespacedName: types.NamespacedName{ Name: app.Name, @@ -252,3 +260,31 @@ func (r *HeliosAppReconciler) findObjectsForSecret(ctx context.Context, obj clie return requests } + +// validateSecretReferences checks if all referenced secrets exist in the cluster. +// This is a pre-flight validation to catch configuration errors early. +// Note: Database secrets are NOT validated here because they are auto-created +// by the operator in Phase 0.5 if database traits are present. +func (r *HeliosAppReconciler) validateSecretReferences(ctx context.Context, app *appv1alpha1.HeliosApp) error { + secretsToValidate := map[string]string{ + "webhook secret": app.Spec.WebhookSecret, + "GitOps secret": app.Spec.GitOpsSecretRef, + // Note: database secret is NOT validated here - it's auto-created in Phase 0.5 + } + + for secretType, secretName := range secretsToValidate { + if secretName == "" { + continue // Skip empty references + } + + var secret corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Name: secretName, Namespace: app.Namespace}, &secret); err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("%s '%s' not found in namespace '%s'", secretType, secretName, app.Namespace) + } + return fmt.Errorf("failed to validate %s '%s': %w", secretType, secretName, err) + } + } + + return nil +} diff --git a/apps/operator/internal/controller/tekton/mapper.go b/apps/operator/internal/controller/tekton/mapper.go index 3136591..9c2ab24 100644 --- a/apps/operator/internal/controller/tekton/mapper.go +++ b/apps/operator/internal/controller/tekton/mapper.go @@ -27,23 +27,30 @@ func MapCRDToTektonInput(app *appv1alpha1.HeliosApp) cueModel.TektonInput { PipelineName: app.Spec.PipelineName, // PipelineType is intentionally derived from PipelineName because // the HeliosApp CRD does not have a separate PipelineType field. - PipelineType: app.Spec.PipelineName, - TriggerType: "gitea-push", - ServiceAccount: app.Spec.ServiceAccount, - PVCName: app.Spec.PVCName, - ContextSubpath: app.Spec.ContextSubpath, - Replicas: int(app.Spec.Replicas), - Port: int(app.Spec.Port), - TestCommand: app.Spec.TestCommand, - DockerSecret: "docker-credentials", - ArgoCDNamespace: app.Spec.ArgoCDNamespace, - ArgoCDProject: app.Spec.ArgoCDProject, + PipelineType: app.Spec.PipelineName, + TriggerType: app.Spec.TriggerType, + ServiceAccount: app.Spec.ServiceAccount, + PVCName: app.Spec.PVCName, + ContextSubpath: app.Spec.ContextSubpath, + Replicas: int(app.Spec.Replicas), + Port: int(app.Spec.Port), + TestCommand: app.Spec.TestCommand, + TestImage: app.Spec.TestImage, + DockerSecret: "docker-credentials", + DatabaseSecretRef: app.Spec.DatabaseSecretRef, + ArgoCDNamespace: app.Spec.ArgoCDNamespace, + ArgoCDProject: app.Spec.ArgoCDProject, } input.GitBranch = cmp.Or(input.GitBranch, "main") input.GitOpsBranch = cmp.Or(input.GitOpsBranch, "main") input.GitOpsSecretRef = cmp.Or(input.GitOpsSecretRef, "helios-gitops-bot") input.WebhookSecret = cmp.Or(input.WebhookSecret, "gitea-webhook-secret") + // Derive the database secret name dynamically from app name if not explicitly set + if input.DatabaseSecretRef == "" { + input.DatabaseSecretRef = app.Name + "-db-secret" + } + input.TriggerType = cmp.Or(input.TriggerType, "gitea-push") if input.PipelineName == "" { input.PipelineName = defaultPipelineName input.PipelineType = defaultPipelineName diff --git a/apps/operator/internal/controller/tekton/pipelinerun.go b/apps/operator/internal/controller/tekton/pipelinerun.go index b3590ac..7641fb8 100644 --- a/apps/operator/internal/controller/tekton/pipelinerun.go +++ b/apps/operator/internal/controller/tekton/pipelinerun.go @@ -26,7 +26,17 @@ func GeneratePipelineRun(heliosApp *appv1alpha1.HeliosApp, pipelineName string) gitOpsSecretRef := cmp.Or(heliosApp.Spec.GitOpsSecretRef, "helios-gitops-bot") argoNS := cmp.Or(heliosApp.Spec.ArgoCDNamespace, "argocd") - params := make([]any, 0, 17) + replicas := heliosApp.Spec.Replicas + if replicas <= 0 { + replicas = 1 + } + port := heliosApp.Spec.Port + if port <= 0 || port > 65535 { + port = 8080 + } + testImage := cmp.Or(heliosApp.Spec.TestImage, "node:24") + + params := make([]any, 0, 18) params = append(params, map[string]any{"name": "app-repo-url", "value": shared.RewriteGiteaURL(heliosApp.Spec.GitRepo)}, map[string]any{"name": "app-repo-revision", "value": appRepoRevision}, @@ -38,9 +48,10 @@ func GeneratePipelineRun(heliosApp *appv1alpha1.HeliosApp, pipelineName string) map[string]any{"name": "GITOPS_AUTHOR_NAME", "value": "Helios Bot"}, map[string]any{"name": "GITOPS_AUTHOR_EMAIL", "value": "helios-bot@helios.local"}, map[string]any{"name": "CONTEXT_SUBPATH", "value": contextSubpath}, - map[string]any{"name": "replicas", "value": fmt.Sprintf("%d", heliosApp.Spec.Replicas)}, - map[string]any{"name": "port", "value": fmt.Sprintf("%d", heliosApp.Spec.Port)}, + map[string]any{"name": "replicas", "value": fmt.Sprintf("%d", replicas)}, + map[string]any{"name": "port", "value": fmt.Sprintf("%d", port)}, map[string]any{"name": "test-command", "value": heliosApp.Spec.TestCommand}, + map[string]any{"name": "test-image", "value": testImage}, map[string]any{"name": "argocd-namespace", "value": argoNS}, map[string]any{"name": "argocd-app-name", "value": heliosApp.Name + "-argocd"}, ) diff --git a/apps/operator/internal/cue/e2e_validation_test.go b/apps/operator/internal/cue/e2e_validation_test.go index fbcfbe0..471ca7d 100644 --- a/apps/operator/internal/cue/e2e_validation_test.go +++ b/apps/operator/internal/cue/e2e_validation_test.go @@ -114,8 +114,8 @@ func TestE2E_CueVsLegacy_TaskNames(t *testing.T) { t.Fatalf("RenderTektonResources failed: %v", err) } - // CUE TaskRegistry: clone, build, gitops update, Argo CD sync - expectedTaskNames := []string{"argocd-sync", "git-clone", "git-update-manifest", "kaniko-build"} + // CUE TaskRegistry: clone, build, gitops update, Argo CD sync, db-migrate, postgrest-reload + expectedTaskNames := []string{"argocd-sync", "db-migrate", "git-clone", "git-update-manifest", "kaniko-build", "postgrest-reload"} slices.Sort(expectedTaskNames) var actualTaskNames []string diff --git a/apps/operator/internal/cue/engine_test.go b/apps/operator/internal/cue/engine_test.go index 2c9e7e6..70ff204 100644 --- a/apps/operator/internal/cue/engine_test.go +++ b/apps/operator/internal/cue/engine_test.go @@ -174,7 +174,7 @@ func TestEngine_RenderWithDatabaseTrait(t *testing.T) { Properties: map[string]any{ "dbType": "postgres", "dbName": "my_custom_db", - "version": "16", + "version": "18.3", }, }, }, diff --git a/apps/operator/internal/cue/tekton.go b/apps/operator/internal/cue/tekton.go index 2614c23..c2feab2 100644 --- a/apps/operator/internal/cue/tekton.go +++ b/apps/operator/internal/cue/tekton.go @@ -58,9 +58,11 @@ type TektonInput struct { // === TESTING === TestCommand string `json:"testCommand,omitempty"` + TestImage string `json:"testImage,omitempty"` // === SECRETS === - DockerSecret string `json:"dockerSecret,omitempty"` + DockerSecret string `json:"dockerSecret,omitempty"` + DatabaseSecretRef string `json:"databaseSecretRef,omitempty"` // === ARGOCD === ArgoCDNamespace string `json:"argoCDNamespace,omitempty"` diff --git a/apps/operator/internal/cue/tekton_test.go b/apps/operator/internal/cue/tekton_test.go index 6d12fb3..083be5f 100644 --- a/apps/operator/internal/cue/tekton_test.go +++ b/apps/operator/internal/cue/tekton_test.go @@ -67,9 +67,11 @@ func TestRenderTektonResources_AllResources(t *testing.T) { t.Fatalf("RenderTektonResources failed: %v", err) } - // With webhookDomain set, we expect 9 objects: - // 4 Tasks + 1 Pipeline + 1 TriggerBinding + 1 TriggerTemplate + 1 EventListener + 1 Ingress - expectedCount := 9 + // With webhookDomain set, we expect 13 objects: + // 6 Tasks (git-clone, kaniko-build, git-update-manifest, argocd-sync, db-migrate, postgrest-reload) + // + 2 Pipelines (from-code-to-cluster + db-migrate) + // + 1 TriggerBinding + 2 TriggerTemplates (gitea + db-migrate) + 1 EventListener + 1 Ingress + expectedCount := 13 if len(objects) != expectedCount { t.Errorf("Expected %d objects, got %d", expectedCount, len(objects)) for i, obj := range objects { @@ -79,10 +81,10 @@ func TestRenderTektonResources_AllResources(t *testing.T) { // Verify each expected kind is present expectedKinds := map[string]int{ - "Task": 4, - "Pipeline": 1, + "Task": 6, + "Pipeline": 2, "TriggerBinding": 1, - "TriggerTemplate": 1, + "TriggerTemplate": 2, "EventListener": 1, "Ingress": 1, } @@ -115,8 +117,9 @@ func TestRenderTektonResources_WithoutWebhook(t *testing.T) { t.Fatalf("RenderTektonResources failed: %v", err) } - // Without webhookDomain: 8 objects (no Ingress) - expectedCount := 8 + // Without webhookDomain: 12 objects (no Ingress) + // 6 Tasks + 2 Pipelines + 1 TriggerBinding + 2 TriggerTemplates + 1 EventListener + expectedCount := 12 if len(objects) != expectedCount { t.Errorf("Expected %d objects (no webhook), got %d", expectedCount, len(objects)) for i, obj := range objects { @@ -174,6 +177,8 @@ func TestRenderTektonResources_CorrectTaskNames(t *testing.T) { "kaniko-build": false, "git-update-manifest": false, "argocd-sync": false, + "db-migrate": false, + "postgrest-reload": false, } for _, obj := range objects { @@ -253,17 +258,15 @@ func TestRenderTektonResources_BuildOnlyPipeline(t *testing.T) { t.Fatalf("RenderTektonResources failed: %v", err) } - // Verify pipeline name is "build-only" - foundPipeline := false + // Verify the primary pipeline with name "build-only" exists among rendered pipelines. + // Note: db-migrate pipeline is always rendered alongside the primary pipeline. + foundBuildOnly := false for _, obj := range objects { - if obj.GetKind() == "Pipeline" { - if obj.GetName() != "build-only" { - t.Errorf("Expected pipeline name 'build-only', got %q", obj.GetName()) - } - foundPipeline = true + if obj.GetKind() == "Pipeline" && obj.GetName() == "build-only" { + foundBuildOnly = true } } - if !foundPipeline { - t.Error("Pipeline not found in rendered objects") + if !foundBuildOnly { + t.Error("Expected primary pipeline 'build-only' not found in rendered objects") } } diff --git a/apps/operator/tekton/pipeline.yaml b/apps/operator/tekton/pipeline.yaml index 00bfee9..cc2140e 100644 --- a/apps/operator/tekton/pipeline.yaml +++ b/apps/operator/tekton/pipeline.yaml @@ -22,7 +22,7 @@ spec: - name: test-command default: "" # Empty => no tests (no-op) - name: test-image - default: "node:20" # Default image for tests; can be overridden per app + default: "node:24" # Default image for tests; can be overridden per app - name: argocd-namespace default: "argocd" - name: argocd-app-name diff --git a/apps/portal/app-config.yaml b/apps/portal/app-config.yaml index 5b5a13e..eda8f48 100644 --- a/apps/portal/app-config.yaml +++ b/apps/portal/app-config.yaml @@ -22,7 +22,9 @@ backend: # Content-Security-Policy directives follow the Helmet format: https://helmetjs.github.io/#reference # Default Helmet Content-Security-Policy values can be removed by setting the key to false cors: - origin: http://localhost:3000 + origin: + - http://localhost:3000 + - http://127.0.0.1:3000 methods: [GET, HEAD, PATCH, POST, PUT, DELETE] credentials: true # This is for local development only, it is not recommended to use this in production @@ -42,6 +44,10 @@ integrations: baseUrl: http://127.0.0.1:3030 username: ${GITEA_USER} password: ${GITEA_TOKEN} + - host: gitea-http.gitea.svc.cluster.local:3000 + baseUrl: http://gitea-http.gitea.svc.cluster.local:3000 + username: ${GITEA_USER} + password: ${GITEA_TOKEN} # Optional: GitHub integration (uncomment if you still need GitHub access) # github: # - host: github.com @@ -60,7 +66,7 @@ proxy: # Local dev: Overridden in app-config.local.yaml endpoints: '/argocd/api': - target: http://argocd-server.argocd.svc.cluster.local/api/v1/ + target: https://127.0.0.1:8080/api/v1/ changeOrigin: true secure: false headers: @@ -132,6 +138,30 @@ catalog: rules: - allow: [Template] + # .NET Web API Template + - type: file + target: ../../examples/dotnet-template/template.yaml + rules: + - allow: [Template] + + # Spring Boot Template + - type: file + target: ../../examples/spring-boot-template/template.yaml + rules: + - allow: [Template] + + # PostgREST Template - Instant REST API over PostgreSQL + - type: file + target: ../../examples/postgrest-template/template.yaml + rules: + - allow: [Template] + + # Hasura GraphQL Engine Template + - type: file + target: ../../examples/hasura-template/template.yaml + rules: + - allow: [Template] + ## Uncomment these lines to add more example data # - type: url # target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all.yaml @@ -161,12 +191,7 @@ kubernetes: serviceLocatorMethod: type: 'multiTenant' clusterLocatorMethods: - - type: 'config' - clusters: - - url: https://kubernetes.default.svc - name: in-cluster - authProvider: 'serviceAccount' - skipTLSVerify: true + - type: 'localKubectlProxy' # Required for Tekton visibility - enables fetching PipelineRuns and TaskRuns customResources: - group: 'tekton.dev' diff --git a/apps/portal/examples/advanced-template/content/gitops/pipeline.yaml b/apps/portal/examples/advanced-template/content/gitops/pipeline.yaml index caf9cdf..eb4855d 100644 --- a/apps/portal/examples/advanced-template/content/gitops/pipeline.yaml +++ b/apps/portal/examples/advanced-template/content/gitops/pipeline.yaml @@ -31,7 +31,7 @@ spec: value: ${{ values.name }}-argocd # (Optional override – can be omitted to use the pipeline default) # - name: test-image - # value: node:20 + # value: node:24 workspaces: - name: source-workspace volumeClaimTemplate: diff --git a/apps/portal/examples/dotnet-template/content/README.md b/apps/portal/examples/dotnet-template/content/README.md new file mode 100644 index 0000000..e20a3ba --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/README.md @@ -0,0 +1,14 @@ +# .NET Web API Backstage Template + +This template scaffolds an ASP.NET Core Web API that is ready for Helios GitOps deployment. + +Generated source repository includes: + +- ASP.NET Core Web API project targeting .NET 10 (runtime 10.0.5, SDK 10.0.202) +- Multi-stage Dockerfile +- docker-compose setup for local development with PostgreSQL 18.3 +- Environment-driven database configuration using `DB_HOST`, `DB_USER`, `DB_PASSWORD`, and `DB_NAME` + +Generated GitOps repository includes: + +- HeliosApp manifest with web-service + database trait diff --git a/apps/portal/examples/dotnet-template/content/gitops/helios-app.yaml b/apps/portal/examples/dotnet-template/content/gitops/helios-app.yaml new file mode 100644 index 0000000..aa2c942 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/gitops/helios-app.yaml @@ -0,0 +1,42 @@ +apiVersion: app.helios.io/v1alpha1 +kind: HeliosApp +metadata: + name: ${{ values.name }} + namespace: default +spec: + + description: "Managed by Helios Operator" + owner: ${{ values.owner }} + + imageRepo: ${{ values.dockerOrg }}/${{ values.repoName }} + gitRepo: "${{ values.sourceRepo }}" + gitopsRepo: ${{ values.gitopsRepo }} + gitopsPath: "apps/${{ values.name }}" + port: ${{ values.port }} + replicas: 1 + testCommand: ${{ values.testCommand }} + testImage: ${{ values.testImage | dump }} + + webhookSecret: "git-credentials-${{ values.name }}" + gitopsSecretRef: "git-credentials-${{ values.name }}" + + components: + - name: ${{ values.name }}-backend + type: web-service + properties: + image: ${{ values.dockerOrg }}/${{ values.repoName }}:main + port: ${{ values.port }} + replicas: 1 + env: + - name: ASPNETCORE_ENVIRONMENT + value: "Production" + traits: + - type: service + properties: + port: ${{ values.port }} + targetPort: ${{ values.port }} + - type: database + properties: + dbType: ${{ values.dbType | dump }} + dbName: ${{ values.dbName | dump }} + version: ${{ values.dbVersion | dump }} diff --git a/apps/portal/examples/dotnet-template/content/source/.dockerignore b/apps/portal/examples/dotnet-template/content/source/.dockerignore new file mode 100644 index 0000000..bf26ace --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/.dockerignore @@ -0,0 +1,6 @@ +bin/ +obj/ +.git/ +.vscode/ +*.user +*.suo diff --git a/apps/portal/examples/dotnet-template/content/source/.env.example b/apps/portal/examples/dotnet-template/content/source/.env.example new file mode 100644 index 0000000..dff2a81 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/.env.example @@ -0,0 +1,11 @@ +# Database credentials (injected by Helios Operator) +DB_HOST=localhost +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=${{ values.name }}-db + +# Legacy compatibility fallback used by some templates/operators +DB_PASS=postgres + +# Application +PORT=${{ values.port }} diff --git a/apps/portal/examples/dotnet-template/content/source/.gitignore b/apps/portal/examples/dotnet-template/content/source/.gitignore new file mode 100644 index 0000000..7800585 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +.vs/ +*.user +*.suo diff --git a/apps/portal/examples/dotnet-template/content/source/Controllers/WeatherForecastController.cs b/apps/portal/examples/dotnet-template/content/source/Controllers/WeatherForecastController.cs new file mode 100644 index 0000000..2d952be --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/Controllers/WeatherForecastController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DotNetApi.Controllers; + +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase +{ + private static readonly string[] Summaries = + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + [HttpGet] + public IEnumerable Get() + { + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + }); + } +} + +public class WeatherForecast +{ + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } +} diff --git a/apps/portal/examples/dotnet-template/content/source/Dockerfile b/apps/portal/examples/dotnet-template/content/source/Dockerfile new file mode 100644 index 0000000..7b9b106 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/Dockerfile @@ -0,0 +1,17 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0.202 AS build +WORKDIR /src + +COPY DotNetApi.csproj ./ +RUN dotnet restore DotNetApi.csproj + +COPY . . +RUN dotnet publish DotNetApi.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0.5 AS runtime +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:${{ values.port }} +EXPOSE ${{ values.port }} + +ENTRYPOINT ["dotnet", "DotNetApi.dll"] diff --git a/apps/portal/examples/dotnet-template/content/source/DotNetApi.csproj b/apps/portal/examples/dotnet-template/content/source/DotNetApi.csproj new file mode 100644 index 0000000..a1564d0 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/DotNetApi.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + latest + latest + true + + + + + + diff --git a/apps/portal/examples/dotnet-template/content/source/Program.cs b/apps/portal/examples/dotnet-template/content/source/Program.cs new file mode 100644 index 0000000..39e9e8a --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/Program.cs @@ -0,0 +1,44 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var dbHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost"; +var dbUser = Environment.GetEnvironmentVariable("DB_USER") ?? "postgres"; +var dbPassword = Environment.GetEnvironmentVariable("DB_PASSWORD") + ?? Environment.GetEnvironmentVariable("DB_PASS") + ?? "postgres"; +var dbName = Environment.GetEnvironmentVariable("DB_NAME") ?? "${{ values.name }}-db"; + +var connectionTemplate = builder.Configuration.GetConnectionString("DefaultConnection") + ?? "Host={DB_HOST};Username={DB_USER};Password={DB_PASSWORD};Database={DB_NAME}"; +var connectionString = connectionTemplate + .Replace("{DB_HOST}", dbHost, StringComparison.Ordinal) + .Replace("{DB_USER}", dbUser, StringComparison.Ordinal) + .Replace("{DB_PASSWORD}", dbPassword, StringComparison.Ordinal) + .Replace("{DB_NAME}", dbName, StringComparison.Ordinal); +builder.Configuration["ConnectionStrings:DefaultConnection"] = connectionString; + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); + + app.MapGet("/database/config", () => + Results.Ok(new + { + Host = dbHost, + User = dbUser, + Database = dbName, + })); +} + +app.UseHttpsRedirection(); +app.MapControllers(); + +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); + +app.Run(); diff --git a/apps/portal/examples/dotnet-template/content/source/Properties/launchSettings.json b/apps/portal/examples/dotnet-template/content/source/Properties/launchSettings.json new file mode 100644 index 0000000..c312bf6 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:${{ values.port }}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/apps/portal/examples/dotnet-template/content/source/README.md b/apps/portal/examples/dotnet-template/content/source/README.md new file mode 100644 index 0000000..fd53fa5 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/README.md @@ -0,0 +1,30 @@ +# ${{ values.name }} (.NET Web API) + +This project is generated by the Helios Backstage .NET template. + +Pinned versions: + +- .NET SDK: 10.0.202 (feature-band for .NET 10.0.5 runtime) +- .NET runtime: 10.0.5 +- PostgreSQL: 18.3 (via Docker Compose) + +## Local Development + +1. Start API + PostgreSQL: + +```bash +docker compose up --build +``` + +2. Open Swagger: + +http://localhost:${{ values.port }}/swagger + +## Environment Variables + +- DB_HOST +- DB_USER +- DB_PASSWORD +- DB_NAME + +The API builds `ConnectionStrings:DefaultConnection` at startup from these values. diff --git a/apps/portal/examples/dotnet-template/content/source/appsettings.Development.json b/apps/portal/examples/dotnet-template/content/source/appsettings.Development.json new file mode 100644 index 0000000..a6e86ac --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/apps/portal/examples/dotnet-template/content/source/appsettings.json b/apps/portal/examples/dotnet-template/content/source/appsettings.json new file mode 100644 index 0000000..8456e98 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Host={DB_HOST};Username={DB_USER};Password={DB_PASSWORD};Database={DB_NAME}" + } +} diff --git a/apps/portal/examples/dotnet-template/content/source/catalog-info.yaml b/apps/portal/examples/dotnet-template/content/source/catalog-info.yaml new file mode 100644 index 0000000..3043aac --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/catalog-info.yaml @@ -0,0 +1,17 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: ${{ values.name }} + description: ${{ values.description | dump }} + annotations: + backstage.io/techdocs-ref: dir:. + backstage.io/kubernetes-id: ${{ values.name }} + backstage.io/kubernetes-label-selector: app.kubernetes.io/name=${{ values.name }} + backstage.io/kubernetes-namespace: default + janus-idp.io/tekton: ${{ values.name }} + tekton.dev/ci-cd: "true" + argocd/app-name: ${{ values.name }}-argocd +spec: + type: service + lifecycle: production + owner: ${{ values.owner }} diff --git a/apps/portal/examples/dotnet-template/content/source/docker-compose.yml b/apps/portal/examples/dotnet-template/content/source/docker-compose.yml new file mode 100644 index 0000000..21f5405 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/docker-compose.yml @@ -0,0 +1,29 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "${{ values.port }}:${{ values.port }}" + environment: + ASPNETCORE_URLS: http://+:${{ values.port }} + ASPNETCORE_ENVIRONMENT: Development + DB_HOST: db + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: ${{ values.name }}-db + depends_on: + - db + + db: + image: postgres:18.3-alpine + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ${{ values.name }}-db + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/apps/portal/examples/dotnet-template/content/source/global.json b/apps/portal/examples/dotnet-template/content/source/global.json new file mode 100644 index 0000000..99b1511 --- /dev/null +++ b/apps/portal/examples/dotnet-template/content/source/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.202", + "rollForward": "latestFeature" + } +} diff --git a/apps/portal/examples/dotnet-template/template.yaml b/apps/portal/examples/dotnet-template/template.yaml new file mode 100644 index 0000000..e325ed3 --- /dev/null +++ b/apps/portal/examples/dotnet-template/template.yaml @@ -0,0 +1,176 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: dotnet-webapi-template + title: .NET Web API Template (Database-backed) + description: Scaffolds an ASP.NET Core Web API service with PostgreSQL-ready GitOps manifests. +spec: + owner: user:guest + type: service + + parameters: + - title: Component Information + required: + - name + - port + - dockerOrg + - repoName + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + port: + title: Port + type: number + description: The port the .NET API listens on + default: 8080 + dockerOrg: + title: Docker Registry Org/User + type: string + description: Your Docker Hub username or Organization + repoName: + title: Docker Repository Name + type: string + description: The name of the Docker repository (e.g. my-dotnet-api) + + - title: Database Configuration + properties: + databaseConfig: + title: Database Settings + type: object + ui:field: DatabasePicker + + - title: Repository & Webhook + required: + - repoUrl + properties: + repoUrl: + title: Source Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - localhost:3030 + + - title: Optional Extras (Local Convenience) + properties: + registerToCatalog: + title: Register component in catalog + type: boolean + default: false + sendNotification: + title: Send Backstage notification + type: boolean + default: false + + steps: + - id: fetch-source + name: Fetch Source Code + action: fetch:template + input: + url: ./content/source + targetPath: ./source + values: + name: ${{ parameters.repoName }} + owner: user:default/${{ user.entity.metadata.name or 'guest' }} + port: ${{ parameters.port }} + description: ".NET Web API service for ${{ parameters.name }}" + image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }} + + - id: publish-source + name: Publish Source Code + action: publish:gitea + input: + description: Source Code for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} + sourcePath: ./source + repoVisibility: public + + - id: create-webhook + name: Create Webhook + action: gitea:create-webhook + input: + repoUrl: ${{ parameters.repoUrl }} + webhookUrl: http://el-${{ parameters.repoName }}-listener.default.svc.cluster.local:8080 + webhookSecret: ${{ parameters.repoName }} + events: + - push + + - id: fetch-gitops + name: Fetch GitOps Manifests + action: fetch:template + input: + url: ./content/gitops + targetPath: ./gitops + values: + name: ${{ parameters.repoName }} + image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }} + dockerOrg: ${{ parameters.dockerOrg }} + repoName: ${{ parameters.repoName }} + port: ${{ parameters.port }} + databaseType: ${{ parameters.databaseConfig.dbType }} + databaseName: ${{ parameters.databaseConfig.dbName }} + owner: user:default/${{ user.entity.metadata.name or 'guest' }} + sourceRepo: ${{ steps['publish-source'].output.remoteUrl }} + gitopsRepo: ${{ steps['publish-source'].output.remoteUrl | replace(".git", "") }}-gitops + testCommand: "dotnet test" + testImage: "mcr.microsoft.com/dotnet/sdk:10.0.202" + dbType: ${{ parameters.databaseConfig.dbType }} + dbName: ${{ parameters.databaseConfig.dbName }} + dbVersion: ${{ parameters.databaseConfig.dbVersion }} + + - id: publish-gitops + name: Publish GitOps Manifests + action: publish:gitea + input: + description: GitOps Manifests for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }}-gitops + sourcePath: ./gitops + repoVisibility: public + + - id: create-secret + name: Create Git Credentials Secret + action: kubernetes:create-git-credentials-secret + input: + name: ${{ parameters.repoName }} + namespace: default + username: ${{ (parameters.repoUrl | parseRepoUrl).owner }} + webhookSecret: ${{ parameters.repoName }} + + - id: apply-helios + name: Deploy to Kubernetes + action: kubernetes:apply + input: + manifestPath: ./gitops/helios-app.yaml + namespaced: true + + - id: register + name: Register Component + action: catalog:register + if: '{{ parameters.registerToCatalog }}' + input: + repoContentsUrl: ${{ steps['publish-source'].output.repoContentsUrl }} + catalogInfoPath: 'catalog-info.yaml' + + - id: notify + name: Notify User + action: notification:send + if: '{{ parameters.sendNotification }}' + input: + recipients: entity + entityRefs: + - user:default/guest + title: '.NET Template Executed' + info: 'Your .NET Web API service has been scaffolded and deployed via GitOps.' + + output: + links: + - title: Source Repository + url: ${{ steps['publish-source'].output.remoteUrl }} + - title: GitOps Repository + url: ${{ steps['publish-gitops'].output.remoteUrl }} + - title: Open in Catalog + icon: catalog + entityRef: ${{ steps['register'].output.entityRef }} diff --git a/apps/portal/examples/hasura-template/content/gitops/catalog-info.yaml b/apps/portal/examples/hasura-template/content/gitops/catalog-info.yaml new file mode 100644 index 0000000..81fe5b9 --- /dev/null +++ b/apps/portal/examples/hasura-template/content/gitops/catalog-info.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: ${{ values.name | dump }} + annotations: + backstage.io/kubernetes-id: ${{ values.name }} + argocd/app-name: ${{ values.name }} +spec: + type: service + owner: ${{ values.owner }} + lifecycle: experimental \ No newline at end of file diff --git a/apps/portal/examples/hasura-template/content/gitops/helios-app.yaml b/apps/portal/examples/hasura-template/content/gitops/helios-app.yaml new file mode 100644 index 0000000..722b824 --- /dev/null +++ b/apps/portal/examples/hasura-template/content/gitops/helios-app.yaml @@ -0,0 +1,51 @@ +apiVersion: app.helios.io/v1alpha1 +kind: HeliosApp +metadata: + name: ${{ values.name }} + namespace: ${{ values.namespace }} +spec: + # Owner and Git Configuration + owner: ${{ values.owner }} + gitRepo: ${{ values.sourceRepo }} + gitBranch: main + imageRepo: ${{ values.image }} + gitopsRepo: ${{ values.gitopsRepo }} + gitopsPath: ${{ values.name }} + webhookSecret: ${{ values.name }}-webhook-secret + port: ${{ values.port }} + replicas: 1 + triggerType: gitea-push + + components: + - name: ${{ values.name }} + type: web-service + properties: + image: ${{ values.image }}:latest + port: ${{ values.port }} + env: + # Directly reference the fully formatted, URL-encoded connection string + # injected by the Operator into the component's database secret. + - name: HASURA_GRAPHQL_DATABASE_URL + valueFrom: + secretKeyRef: + name: ${{ values.name }}-db-secret + key: PGRST_DB_URI + + - name: HASURA_GRAPHQL_ENABLE_CONSOLE + value: "true" + traits: + - type: database + properties: + dbType: ${{ values.databaseType | default("postgres") }} + dbName: ${{ values.databaseName or values.name }}-db + port: 5432 + version: "${{ values.databaseVersion or '16' }}" + storage: "1Gi" + - type: service + properties: + port: ${{ values.port }} + protocol: HTTP + ingress: + enabled: true + host: ${{ values.name }}.local + path: / \ No newline at end of file diff --git a/apps/portal/examples/hasura-template/content/gitops/namespace.yaml b/apps/portal/examples/hasura-template/content/gitops/namespace.yaml new file mode 100644 index 0000000..32ee8c9 --- /dev/null +++ b/apps/portal/examples/hasura-template/content/gitops/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ${{ values.namespace }} + labels: + helios.io/managed-by: helios-operator \ No newline at end of file diff --git a/apps/portal/examples/hasura-template/content/source/.gitkeep b/apps/portal/examples/hasura-template/content/source/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/portal/examples/hasura-template/content/source/Dockerfile b/apps/portal/examples/hasura-template/content/source/Dockerfile new file mode 100644 index 0000000..1551f61 --- /dev/null +++ b/apps/portal/examples/hasura-template/content/source/Dockerfile @@ -0,0 +1,15 @@ +FROM hasura/graphql-engine:v2.33.0.cli-migrations-v3 + +# Create the migrations directory and copy our initial SQL file +RUN mkdir -p /hasura-migrations +COPY migrations/ /hasura-migrations/ + +# TEMPORARILY COMMENT THIS OUT FOR THE TEMPLATE +# Developers will uncomment this after they use the Hasura CLI to generate metadata +# RUN mkdir -p /hasura-metadata +# COPY metadata/ /hasura-metadata/ + +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/healthz || exit 1 + +EXPOSE 8080 \ No newline at end of file diff --git a/apps/portal/examples/hasura-template/content/source/README.md b/apps/portal/examples/hasura-template/content/source/README.md new file mode 100644 index 0000000..1d075ba --- /dev/null +++ b/apps/portal/examples/hasura-template/content/source/README.md @@ -0,0 +1,270 @@ +# ${{ values.name }} - Hasura GraphQL API + + + +This is the source repository for a Hasura GraphQL Engine instance, scaffolded by the Helios Portal. + + + +Hasura provides an instant, real-time GraphQL API over a PostgreSQL database. This repository contains the build instructions, database migrations, and metadata configuration for your custom Hasura image. + + + +--- + + + +## 🏗️ Architecture & Workflow + + + +This project uses a fully automated **GitOps and CI/CD** workflow managed by the Helios Platform. By utilizing the official Hasura `cli-migrations-v3` image, your database schema and GraphQL API remain perfectly synchronized with your Git repository. + + + +```text + +1. You commit SQL migrations/metadata to THIS Source Repository + + ↓ + +2. Gitea Webhook triggers Tekton CI/CD Pipeline + + ↓ + +3. Tekton builds your custom Docker image and pushes to Docker Hub + + ↓ + +4. Tekton automatically updates the GitOps Repository with the new image tag + + ↓ + +5. ArgoCD detects the change and syncs the Kubernetes manifests + + ↓ + +6. Helios Operator provisions/updates the PostgreSQL Database + + ↓ + +7. Hasura container rolls out, automatically applying your SQL migrations on startup! + +``` + + + +📂 Generated Repositories + +------------------------- + + + +This template generated **two** repositories for your application: + + + +### 1\. The Source Repository (This Repo) + + + +Contains your application build instructions and database state: + + + +* Dockerfile: Builds your custom Hasura image. It is pre-configured to copy your migrations into the container. + + + +* migrations/: Contains your raw SQL files. A base migration (init) is already provided. + + + +* metadata/: Tracks your Hasura API configuration (table tracking, relationships, permissions). + + + +* README.md: This documentation file. + + + + + +### 2\. The GitOps Repository (\*-gitops) + + + +Contains your Kubernetes infrastructure declarations: + + + +* helios-app.yaml: The Custom Resource that tells the Helios Operator how to deploy Hasura and natively wire up the PostgreSQL database connection. + + + +* catalog-info.yaml: Registers your component in the Backstage catalog. + + + + + +🚀 Getting Started + +------------------ + + + +### 1\. Accessing the Hasura Console + + + +The Helios Operator automatically deploys your Hasura instance with the console enabled. To access it locally during development: + + + +1. Run kubectl port-forward svc/${{ values.name }} 8085:8080 -n default (update the namespace if needed). + + + +2. Open your browser and navigate to http://localhost:8085/console. + + + + + +### 2\. The Initial Database State + + + +We have pre-populated your database with a base migration! + + + +1. In the Hasura Console, click the **DATA** tab. + + + +2. Expand the default database and click the public schema. + + + +3. Under **Untracked tables or views**, you will see a users table. + + + +4. Click **Track** to expose this table to your GraphQL API. + + + + + +🛠️ The GitOps Migration Workflow + +--------------------------------- + + + +To manage your database schema, you do not need external tools. You just write SQL and push! + + + +### Adding a New Table + + + +1. mkdir -p migrations/default/1700000000001\_create\_posts/ + + + +2. SQLCREATE TABLE public.posts ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, content TEXT); + + + +3. Commit and push the code. + + + +4. The Tekton pipeline will automatically build the new Docker image, ArgoCD will roll it out, and Hasura will automatically execute your new SQL file against the database! + + + +5. Open the Hasura Console to track your new table. + + + + + +⚙️ Managing Metadata (Crucial Step) + +----------------------------------- + + + +"Metadata" is how Hasura remembers which tables are exposed to the GraphQL API, your relationships, and your security permissions. + + + +**Currently, metadata copying is DISABLED in your Dockerfile so the initial deployment doesn't crash on an empty configuration.** + + + +Once you track your first tables in the Hasura UI, you need to save that configuration back to this Git repository: + + + +1. Install the [Hasura CLI](https://www.google.com/search?q=https://hasura.io/docs/latest/hasura-cli/install-hasura-cli/). + + + +2. Initialize a Hasura project locally or connect to your running instance. + + + +3. Run hasura metadata export to generate the .yaml files in your metadata/ directory. + + + +4. DockerfileRUN mkdir -p /hasura-metadataCOPY metadata/ /hasura-metadata/ + + + +5. Commit and push. From now on, your API configuration is strictly version-controlled! + + + + + +🚑 Troubleshooting + +------------------ + + + +**1\. The pipeline didn't trigger when I pushed code:**Ensure the Gitea Webhook is configured correctly in your repository settings to point to the Tekton EventListener. + + + +**2\. Hasura cannot connect to the database (CrashLoopBackOff):**Check the logs of the Helios Operator. Verify that the database was provisioned. Hasura relies on the explicit env variable mapping in your GitOps helios-app.yaml to construct the HASURA\_GRAPHQL\_DATABASE\_URL from the Operator's injected secrets. + + + +**3\. Hasura crashes on startup with a parse-failed metadata error:**This means you uncommented the metadata COPY step in the Dockerfile, but your metadata/ folder doesn't contain valid Hasura configuration files yet. Comment it back out until you have exported your metadata via the CLI. + + + +📚 Useful Links + +--------------- + + + +* [Hasura Official Documentation](https://www.google.com/search?q=https://hasura.io/docs/latest/index/) + + + +* [Learn GraphQL Basics](https://graphql.org/learn/) + + + +* [Helios Operator Documentation](https://www.google.com/search?q=../../../../docs/OPERATOR.md) + diff --git a/apps/portal/examples/hasura-template/content/source/metadata/.gitkeep b/apps/portal/examples/hasura-template/content/source/metadata/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/portal/examples/hasura-template/content/source/migrations/.gitkeep b/apps/portal/examples/hasura-template/content/source/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/portal/examples/hasura-template/content/source/migrations/default/1700000000000_init/up.sql b/apps/portal/examples/hasura-template/content/source/migrations/default/1700000000000_init/up.sql new file mode 100644 index 0000000..c8e5cc5 --- /dev/null +++ b/apps/portal/examples/hasura-template/content/source/migrations/default/1700000000000_init/up.sql @@ -0,0 +1,12 @@ +-- Create a test users table +CREATE TABLE public.users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Insert some dummy data +INSERT INTO public.users (name, email) VALUES +('Alice', 'alice@example.com'), +('Bob', 'bob@example.com'); \ No newline at end of file diff --git a/apps/portal/examples/hasura-template/template.yaml b/apps/portal/examples/hasura-template/template.yaml new file mode 100644 index 0000000..04a0c6d --- /dev/null +++ b/apps/portal/examples/hasura-template/template.yaml @@ -0,0 +1,183 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: hasura-graphql-template + title: Hasura GraphQL API Template + description: >- + Scaffolds a Hasura GraphQL Engine instance connected to a + dynamically provisioned PostgreSQL database. + The Helios Operator automatically injects the connection string (PGRST_DB_URI). +spec: + owner: user:guest + type: service + + parameters: + - title: Component Information + required: [name, port, dockerOrg, repoName] + properties: + name: + title: Name + type: string + description: Unique name of the Hasura instance + ui:autofocus: true + port: + title: Port + type: number + description: The port Hasura listens on (default is 8080) + default: 8080 + dockerOrg: + title: Docker Registry Org/User + type: string + description: Your Docker Hub username or Organization + repoName: + title: Docker Repository Name + type: string + description: The name of the Docker repository (e.g. my-hasura) + + - title: Database Configuration + properties: + databaseConfig: + title: Database Settings + type: object + ui:field: DatabasePicker + + - title: GitOps & Deployment + required: [repoUrl] + properties: + repoUrl: + title: GitOps Repository Location + type: string + description: Where to store the Kubernetes manifests + ui:field: RepoUrlPicker + ui:options: + allowedHosts: [localhost:3030] + namespace: + title: Kubernetes Namespace + type: string + description: Namespace where the service will be deployed + default: default + + - title: Optional Extras + properties: + registerToCatalog: + title: Register component in catalog + type: boolean + default: true + sendNotification: + title: Send Backstage notification + type: boolean + default: false + + steps: + # 1. Fetch minimal source content (for consistency with PostgREST) + - id: fetch-source + name: Fetch Source Content + action: fetch:template + input: + url: ./content/source + targetPath: ./source + values: + name: ${{ parameters.name }} + + # 2. Publish source repo (this gives us real Git URLs) + - id: publish-source + name: Publish Source Repo + action: publish:gitea + input: + description: Hasura Configuration for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} + sourcePath: ./source + repoVisibility: public + + # 3. Create the Webhook + - id: create-webhook + name: Create Gitea Webhook + action: gitea:create-webhook + input: + repoUrl: ${{ parameters.repoUrl }} + # FIX: Replaced 'default' with dynamic namespace variable + webhookUrl: http://el-${{ parameters.name }}-listener.${{ parameters.namespace }}.svc.cluster.local:8080 + webhookSecret: ${{ parameters.name }}-webhook-secret + events: + - push + + # 4. Fetch GitOps Manifests + - id: fetch-gitops + name: Fetch GitOps Manifests + action: fetch:template + input: + url: ./content/gitops + targetPath: ./gitops + values: + name: ${{ parameters.name }} + port: ${{ parameters.port }} + # FIX: Passed namespace into the gitops templates + namespace: ${{ parameters.namespace }} + databaseName: ${{ parameters.databaseConfig.dbName }} + databaseType: ${{ parameters.databaseConfig.dbType }} + owner: ${{ user.entity.metadata.name or 'guest' }} + image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }} + sourceRepo: ${{ steps['publish-source'].output.remoteUrl }} + gitopsRepo: ${{ steps['publish-source'].output.remoteUrl | replace(".git", "") }}-gitops + + - id: publish-gitops + name: Publish GitOps Manifests + action: publish:gitea + input: + description: GitOps Manifests for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }}-gitops + sourcePath: ./gitops + repoVisibility: public + + # FIX: Added Namespace Creation Step + - id: apply-namespace + name: Create Kubernetes Namespace + action: kubernetes:apply + input: + manifestPath: ./gitops/namespace.yaml + + # 5. Create webhook secret in cluster + - id: create-webhook-secret + name: Create Kubernetes Webhook Secret + action: kubernetes:create-secret + input: + # FIX: Replaced 'default' with dynamic namespace variable + namespace: ${{ parameters.namespace }} + secretName: ${{ parameters.name }}-webhook-secret + data: + secret: ${{ parameters.name }}-webhook-secret + + # 6. Deploy HeliosApp + - id: apply-helios + name: Deploy to Kubernetes + action: kubernetes:apply + input: + manifestPath: ./gitops/helios-app.yaml + namespaced: true + + # 7. Registration + - id: register + name: Register Component + action: catalog:register + if: '{{ parameters.registerToCatalog }}' + input: + repoContentsUrl: ${{ steps['publish-gitops'].output.repoContentsUrl }} + catalogInfoPath: 'catalog-info.yaml' + + - id: notify + name: Notify User + action: notification:send + if: '{{ parameters.sendNotification }}' + input: + recipients: entity + entityRefs: ["user:default/guest"] + title: 'Hasura Template Executed' + info: 'Your Hasura instance is being deployed!' + + output: + links: + - title: GitOps Repository + url: ${{ steps['publish-gitops'].output.remoteUrl }} + - title: Open in Catalog + icon: catalog + entityRef: ${{ steps['register'].output.entityRef }} \ No newline at end of file diff --git a/apps/portal/examples/nestjs-prisma-template/template.yaml b/apps/portal/examples/nestjs-prisma-template/template.yaml index c455707..01e6f47 100644 --- a/apps/portal/examples/nestjs-prisma-template/template.yaml +++ b/apps/portal/examples/nestjs-prisma-template/template.yaml @@ -3,10 +3,7 @@ kind: Template metadata: name: nestjs-prisma-template title: NestJS + Prisma Template (Database-backed) - description: >- - Scaffolds a NestJS service with Prisma ORM and a PostgreSQL database. - The Helios Operator automatically provisions the database and injects - credentials (DB_HOST, DB_USER, DB_PASS) into the backend pod. + description: Scaffolds a NestJS service with Prisma and PostgreSQL-ready GitOps manifests. spec: owner: user:guest type: service diff --git a/apps/portal/examples/org.yaml b/apps/portal/examples/org.yaml index 964b533..770674b 100644 --- a/apps/portal/examples/org.yaml +++ b/apps/portal/examples/org.yaml @@ -16,6 +16,15 @@ spec: type: team children: [] --- +# Compatibility alias for templates/components that set owner as plain 'guest' +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: guest +spec: + type: team + children: [] +--- # https://backstage.io/docs/features/software-catalog/descriptor-format#kind-group apiVersion: backstage.io/v1alpha1 kind: Group diff --git a/apps/portal/examples/postgrest-template/MIGRATION_SETUP.md b/apps/portal/examples/postgrest-template/MIGRATION_SETUP.md new file mode 100644 index 0000000..dae5328 --- /dev/null +++ b/apps/portal/examples/postgrest-template/MIGRATION_SETUP.md @@ -0,0 +1,491 @@ +# PostgREST Template: Database Migration Pipeline + +This guide explains how the database migration pipeline works end-to-end and how to use it with your PostgREST API. + +## Overview + +The **db-migrate pipeline** automatically runs database migrations and reloads the PostgREST schema cache when changes are committed to the `db/migrations/` directory. This ensures your API immediately reflects the latest database schema without rebuilding container images. + +### Pipeline Steps + +``` +Git Commit (to db/migrations/) + ↓ +EventListener (filters db/** changes) + ↓ +PipelineRun (db-migrate) + ├─ Clone Repository + ├─ Run golang-migrate + └─ Trigger Schema Reload (NOTIFY) + ↓ +PostgREST API (schema updated, no restart needed) +``` + +--- + +## Architecture + +### 1. Migration Tool: golang-migrate + +**Why golang-migrate?** +- ✅ Clean separation of up/down migrations +- ✅ Works in containers (no Go installation needed) +- ✅ Tracks migration status in database schema_migrations table +- ✅ Supports multiple databases (PostgreSQL, MySQL, etc.) +- ✅ Idempotent (safe to re-run) + +**Used Image:** `migrate/migrate:v4.17.0` + +**Key Features:** +- Version-based migrations (000001_, 000002_, etc.) +- Up/Down SQL files for each version +- Automatic transaction handling per migration +- Database state tracking to prevent duplicate runs + +**Reference:** [golang-migrate Documentation](https://github.com/golang-migrate/migrate/blob/master/database/postgres/TUTORIAL.md) + +### 2. Schema Reload: PostgreSQL NOTIFY + +**Standard Mechanism:** +```sql +NOTIFY pgrst, 'reload schema'; +``` + +**Why NOTIFY instead of Kubernetes rollout restart?** +- ✅ **Zero Downtime:** API stays running, schema reloaded in milliseconds +- ✅ **Cleaner:** No pod restarts, no session disruption +- ✅ **Scalable:** Works with multiple replicas without rollout waits +- ✅ **PostgREST Designed For:** PostgREST specifically watches for this NOTIFY event +- ✅ **Reliable:** PostgreSQL guarantees event delivery to all connected clients + +**How PostgREST Listens:** +PostgREST automatically listens for the `pgrst` channel. When it receives `'reload schema'`, it: +1. Introspects the database schema again +2. Rebuilds its internal API definition +3. Applies changes immediately to subsequent requests + +### 3. Git Trigger: CEL Filter + +**Trigger Configuration:** +- **File:** `content/gitops/triggers.yaml` (generated from CUE) +- **Filter:** Only fires when commits modify `db/**` path +- **Uses:** CEL (Common Expression Language) interceptor +- **Ignores:** Changes to other directories (code, docs, etc.) + +**Example Filter Logic:** +``` +has(body.commits) && +body.commits.filter(c, has(c.modified) && c.modified.exists(m, m.startsWith('db/'))).size() > 0 +``` + +This ensures the migration pipeline: +- Runs automatically on `db/migrations/` changes +- Ignores code changes, reducing noise +- Stays focused on its mission (migrations) + +--- + +## How to Add a New Migration + +### Step 1: Create Migration Files + +Migrations go in `db/migrations/` with the naming convention: `NNNNNN_description.{up,down}.sql` + +```bash +# Create two files in db/migrations/ + +# File: db/migrations/000002_add_users_table.up.sql +-- Create users table +CREATE TABLE IF NOT EXISTS api.users ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +GRANT SELECT, INSERT, UPDATE, DELETE ON api.users TO authenticated; + + +# File: db/migrations/000002_add_users_table.down.sql +-- Rollback users table +DROP TABLE IF EXISTS api.users CASCADE; +``` + +### Step 2: Commit Your Changes + +```bash +git add db/migrations/000002_add_users_table.* +git commit -m "feat: add users table" +git push origin main +``` + +### Step 3: Pipeline Automatically Runs + +1. **Webhook Fires:** Gitea sends webhook to EventListener +2. **Filter Matches:** EventListener sees `db/migrations/` in changed files +3. **PipelineRun Created:** Tekton schedules the `db-migrate` pipeline +4. **Migration Executes:** + - Task 1: Clone the repository + - Task 2: Run `golang-migrate up` on `db/migrations/` + - Task 3: Execute `NOTIFY pgrst, 'reload schema';` +5. **Done:** API automatically serves the new schema + +### Step 4: Verify + +```bash +# Check PostgREST API now includes new endpoints +curl http://your-postgrest-api/users + +# Check the schema_migrations table +psql $DATABASE_URL -c "SELECT * FROM schema_migrations;" +``` + +--- + +## Security: Secrets & Networking + +### Database Access in Pipeline + +The migration pipeline needs access to the database. This is handled via: + +**1. Kubernetes Secret Injection:** +- Secret Name: `{app-name}-db` (created by Helios Operator) +- Contains: `DB_USER`, `DB_PASS`, `DB_HOST`, `DB_PORT`, `PGRST_DB_URI` +- Mounted to migration task pod + +**2. Volume Mount:** +```yaml +volumes: + - name: db-credentials + secret: + secretName: myapp-db + +volumeMounts: + - name: db-credentials + mountPath: /etc/db-credentials + readOnly: true +``` + +**3. Environment Variable:** +```bash +DATABASE_URL=postgres://user:pass@host:5432/dbname +``` + +**Network Access:** +- Database must be reachable from Tekton cluster +- Typically run in same Kubernetes cluster (internal DNS) +- Operator provision prevents network issues + +### Least Privilege + +The task uses the database user created by the Operator: +- Username: Randomly generated (8 chars) +- Password: Randomly generated (32 chars) +- Permissions: Only owns the application database +- No superuser or dangerous privileges + +--- + +## CUE Pipeline Structure + +### 1. Pipeline Definition + +**File:** `cue/definitions/tekton/pipelines/db-migrate.cue` + +```cue +#PipelineRegistry: "db-migrate": { + name: "db-migrate" + description: "Database migration pipeline for PostgREST" + config: { + params: [ + {name: "app-repo-url", type: "string"}, + {name: "app-repo-revision", type: "string"}, + {name: "database-url", type: "string"}, + {name: "migration-source", type: "string", default: "db/migrations"}, + ] + tasks: [ + {name: "clone-repo", taskRef: {name: "git-clone"}, ...}, + {name: "run-migrations", taskRef: {name: "db-migrate"}, ...}, + {name: "reload-postgrest", taskRef: {name: "postgrest-reload"}, ...}, + ] + } +} +``` + +### 2. Task Registry + +**File:** `cue/definitions/tekton/tasks/registry.cue` + +```cue +#TaskRegistry: { + "git-clone": #GitClone + "db-migrate": #DBMigrate + "postgrest-reload": #PostgRESTReload +} +``` + +### 3. Trigger Registry + +**File:** `cue/definitions/tekton/triggers/registry.cue` + +```cue +#TriggerRegistry: { + "db-migrate": #DatabaseMigrationTriggerBundle +} +``` + +All pieces are registered in CUE and automatically rendered as Kubernetes resources. + +--- + +## HeliosApp Configuration + +Your template includes automatic db-migrate trigger: + +```yaml +apiVersion: helios.io/v1alpha1 +kind: HeliosApp +metadata: + name: my-postgrest-api +spec: + # This line enables the db-migrate trigger + triggerType: db-migrate + + # Operator will provision database with these traits + components: + - name: api + traits: + - type: database + properties: + dbType: postgres + dbName: my-api-db + port: 5432 +``` + +--- + +## Running Migrations Manually + +If you need to run migrations outside the automated pipeline: + +### Option 1: Via kubectl (pod exec) + +```bash +# Get the PostgreSQL pod name +kubectl get pods -n default -l app=my-api-db + +# Exec into the pod and run migrations manually +kubectl run -it --rm migration-job \ + --image=migrate/migrate:v4.17.0 \ + --restart=Never \ + -- migrate -path /migrations -database "$DATABASE_URL" up +``` + +### Option 2: Trigger PipelineRun Manually + +```bash +kubectl create -f - </:latest + ↓ +ArgoCD detects new image + ↓ +Kubernetes restarts PostgREST pod (rolling deployment) + ↓ +Old requests drain, new requests use new schema +``` + +### GitOps Repository + +Infrastructure declarations: + +``` +gitops-repo/ + ├── helios-app.yaml # Main application definition + │ # (database + postgrest + webhook settings) + ├── namespace.yaml # Kubernetes namespace + ├── kustomization.yaml # Bundle all manifests + ├── tekton/ + │ ├── eventlistener.yaml # Webhook configuration + │ ├── triggerbinding.yaml # Extract params from webhook + │ └── triggertemplate.yaml # Define what to run + └── README.md # Deployment documentation +``` + +**How webhooks work:** + +1. Source repo has webhook pointing to: `http://el--listener..svc.cluster.local:8080` +2. Every git push sends a JSON payload to that endpoint +3. EventListener validates signature + filters by branch/path +4. Matching commits trigger a PipelineRun +5. Pipeline executes in Kubernetes (build, test, push) + +**db-migrate webhook is separate:** + +1. db-migrate EventListener listens for changes to `db/migration/` folder only +2. Different trigger than the main CI/CD webhook +3. Only executes golang-migrate, doesn't rebuild image +4. Runs in <10 seconds (no container rebuild) + +## Complete Workflow + +### Initial Setup (One-Time) + +1. **Scaffold via Backstage UI** + ``` + Backstage → Create Component → PostgREST API Template + Fill in: name, registry, namespace, database settings + Template creates: source + gitops repos with webhooks + ``` + +2. **Deploy to Kubernetes** (Manual, one-time) + ```bash + cd gitops/ + kubectl apply -f helios-app.yaml + # Helios Operator creates PostgreSQL database + PostgREST container + ``` + +3. **Get your API endpoint** + ```bash + kubectl get ingress -n + # Your REST API is now live! + ``` + +### Day-to-Day Operations + +#### Add Features (Code Changes) +```bash +# 1. Edit your code in source repo +nano postgrestrc.conf +vim schema/01-tables.sql + +# 2. Push changes +git add . +git commit -m "Add profile schema" +git push origin main + +# 3. Automatic CI/CD +# Webhook triggers Tekton: +# ✓ Builds Docker image +# ✓ Runs tests +# ✓ Pushes to registry +# +# GitOps repo auto-updates with new image version +# ArgoCD syncs → PostgREST pod restarts with new code +``` + +#### Migrate Database (Zero Downtime) +```bash +# 1. Create migration files +mkdir -p db/migration +cat > db/migration/003_add_users.up.sql << 'EOF' +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +EOF + +cat > db/migration/003_add_users.down.sql << 'EOF' +DROP TABLE IF EXISTS users; +EOF + +# 2. Push migration files +git add db/migration/ +git commit -m "Add users table" +git push origin main + +# 3. Automatic db-migrate pipeline +# (You don't do anything else!) +# +# Webhook triggers db-migrate pipeline: +# ✓ Clones repository +# ✓ Runs golang-migrate +# ✓ Updates schema cache +# ✓ API continues working +# +# kubectl logs -f deployment/el-{name}-db-migrate-listener +# to watch the webhook trigger + +# Done! Your new endpoint is live: +# GET /users +# POST /users (insert) +# PATCH /users (update) +# DELETE /users (delete) +``` + +#### Update GitOps Configuration +```bash +# 1. Edit manifests in gitops repo +nano helios-app.yaml # Change replicas, ports, etc + +# 2. Push changes +git add . +git commit -m "Scale to 3 replicas" +git push origin main + +# 3. Automatic sync +# GitOps webhook triggers +# ArgoCD syncs → Kubernetes updates +``` + +## Getting Started + +### Prerequisites +- Kubernetes 1.28+ with Helios Operator 0.2.0+ +- ArgoCD 2.8+ installed +- Backstage instance with scaffolder plugin +- Docker Hub account (for image registry) + +### Step 1: Create Your API via Backstage + +``` +Backstage → "Create Component" button → "PostgREST API" template + +Fill in: + Component Name: my-awesome-api + Docker Org: mycompany (or Docker Hub username) + Repository Name: my-awesome-api + API Port: 3000 + Namespace: production (or your target namespace) + JWT Secret: (random string, 32+ chars) + Database Name: my-awesome-api-db +``` + +**What gets created:** +- ✅ Source repository with Dockerfile, schema templates +- ✅ GitOps repository with deployed manifests +- ✅ Git webhooks (automatically registered) + +### Step 2: Deploy to Your Cluster + +```bash +# Clone the generated gitops repository +git clone https://your-gitea/mycompany/my-awesome-api-gitops.git +cd my-awesome-api-gitops + +# Deploy HeliosApp (this is what everything depends on) +kubectl apply -f helios-app.yaml + +# Watch rollout +kubectl rollout status deployment/api -n production --timeout=5m + +# Get your API endpoint +kubectl get ingress -n production +# Result: my-awesome-api.company.internal or https://api.company.internal +``` + +### Step 3: Design Your Database Schema + +Edit the source repository (`schema/` folder): + +```bash +# Clone source repository +git clone https://your-gitea/mycompany/my-awesome-api.git +cd my-awesome-api + +# Edit schema files +cat > schema/01-tables.sql << 'EOF' +CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + body TEXT, + author_id INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL +); +EOF + +cat > schema/02-permissions.sql << 'EOF' +-- Allow public read-only access +GRANT SELECT ON posts, users TO anon; +GRANT SELECT ON posts, users TO authenticated; + +-- Allow authenticated users to create posts +GRANT INSERT ON posts TO authenticated; +EOF + +# Commit and push +git add schema/ +git commit -m "Add posts and users tables" +git push origin main +``` + +Your schema is now compiled into the Docker image. **Automatic pipeline triggers:** +- Builds Docker image with schema +- Pushes to docker.io/mycompany/my-awesome-api:latest +- ArgoCD detects the change and redeploys +- **Your API endpoints are live** (e.g., GET /posts, POST /posts) + +### Step 4: Evolve Your Schema (Zero-Downtime Migrations) + +Instead of rebuilding images, use migrations: + +```bash +# Create migration files +mkdir -p db/migration + +cat > db/migration/001_initial.up.sql << 'EOF' +CREATE TABLE posts (id SERIAL PRIMARY KEY, title TEXT, author_id INTEGER); +CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT, email TEXT UNIQUE); +EOF + +cat > db/migration/001_initial.down.sql << 'EOF' +DROP TABLE IF EXISTS posts, users; +EOF + +# Later: add a new feature without redeploying +cat > db/migration/002_add_published_at.up.sql << 'EOF' +ALTER TABLE posts ADD COLUMN published_at TIMESTAMP; +EOF + +cat > db/migration/002_add_published_at.down.sql << 'EOF' +ALTER TABLE posts DROP COLUMN published_at; +EOF + +# Push your changes +git add db/migration/ +git commit -m "Add published_at to posts" +git push origin main + +# ✨ MAGIC: db-migrate pipeline automatically: +# • Clones repo +# • Runs golang-migrate +# • Reloads PostgREST +# • No downtime, no redeployment, no pod restarts +``` + +Monitor the migration: + +```bash +# Check webhook triggered +kubectl get eventlistener -w -n production +kubectl logs deployment/el-my-awesome-api-db-migrate-listener -n production + +# Check migration results +kubectl get pipelinerun -n production | grep db-migrate +kubectl describe pipelinerun -n production + +# Your new endpoints work immediately +curl https://api.company.internal/posts # includes published_at +``` + +## Troubleshooting + +### API Not Responding +```bash +# Check PostgREST pod is running +kubectl get pod -n production -l app=api + +# Check logs for connection errors +kubectl logs deployment/api -n production + +# Verify database is running +kubectl get pod -n production -l app=api-db + +# Test DB connection from API pod +kubectl exec -it deployment/api -n production -- \ + sh -c 'curl -s http://localhost:3000/ | head' +``` + +### Migrations Not Triggering +```bash +# Check db-migrate EventListener is running +kubectl get eventlistener -n production + +# Check if webhook is registered in Gitea +kubectl logs deployment/el-my-awesome-api-db-migrate-listener -n production -f --tail=50 + +# Manually trigger by pushing to db/migration/ folder: +echo "-- test" > db/migration/999_test.up.sql +git add db/migration/999_test.up.sql +git commit -m "Trigger migration" +git push origin main + +# Watch the pipeline get created +kubectl get pipelinerun -n production -w +``` + +### Failed Migrations +```bash +# Check migration run status +kubectl get pipelinerun -n production -l tekton.dev/pipeline=db-migrate + +# Get detailed error +kubectl describe pipelinerun -n production + +# Check migration logs +kubectl logs -f -l tekton.dev/pipeline=db-migrate -c step-migrate -n production + +# Common issues: +# - SQL syntax error: Check *.up.sql file format +# - File naming: Must be NNN_description.up.sql (with leading zeros) +# - Database not found: Check dbName matches in helios-app.yaml +``` + +### CI/CD Pipeline Not Triggering +```bash +# Check Tekton EventListener for source code changes +kubectl get eventlistener -n production + +# Verify webhook URL in Gitea +# Should be: http://el-my-awesome-api-listener.production.svc.cluster.local:8080 + +# Check Tekton controller logs +kubectl logs -f deployment/tekton-triggers-controller -n tekton-pipelines + +# Manual test: push any file to source repo +git add . +git commit -m "Test CI/CD" --allow-empty +git push origin main + +# Check if PipelineRun gets created +kubectl get pipelinerun -n production -w +``` + +## Common Patterns + +### JWT Authentication +```bash +# Generate a strong JWT secret +openssl rand -base64 32 + +# Pass to template when creating component +JWT Secret: + +# Clients use JWT token to access protected endpoints: +AUTH_TOKEN=$(your-auth-system-generates-jwt) +curl -H "Authorization: Bearer $AUTH_TOKEN" https://api.company.internal/protected-data +``` + +### Role-Based Access Control +```sql +-- In schema/02-permissions.sql +CREATE ROLE anon NOLOGIN; +CREATE ROLE authenticated NOLOGIN; + +-- Public endpoints (unauthenticated) +GRANT SELECT ON public_posts TO anon; + +-- Private endpoints (authenticated users only) +GRANT SELECT ON user_profile TO authenticated; +GRANT INSERT, UPDATE ON user_profile TO authenticated; +``` + +### Custom Docker Image +The template uses `postgrest/postgrest:latest` as base, allowing you to: +- Add system packages +- Install extensions +- Configure PostgREST before runtime + +Edit `Dockerfile` in source repo: +```dockerfile +FROM postgrest/postgrest:v12.2.0 + +# Add custom packages or configs +RUN apt-get update && apt-get install -y custom-tool + +# Copy your schema +COPY schema/ /schema/ + +# Build pushes to docker.io//:latest +``` + +## Files in This Template + +- **template.yaml** - Backstage template definition +- **validate.sh** - Template validation script +- **content/source/** - Source repository template +- **content/gitops/** - GitOps repository template +- **README.md** (this file) - Template documentation + +## Next Steps + +1. Register this template in Backstage (`catalog-info.yaml`) +2. Users access via Backstage UI → Create Component → PostgREST API Template +3. Follow the scaffolding flow to generate their repositories +4. See generated `README.md` in source repo for customization guide + +## Quick Reference + +### Common Commands +```bash +# Monitor API deployment +kubectl get pods -n production -l app=api +kubectl logs -f deployment/api -n production + +# Watch database migrations +kubectl get pipelinerun -n production -l tekton.dev/pipeline=db-migrate +kubectl logs -f pipelinerun/ -n production + +# Check webhook integration +kubectl get eventlistener -n production +kubectl logs -f deployment/el-my-awesome-api-listener -n production + +# Direct database access +kubectl port-forward -n production svc/api-db 5432:5432 +psql -h localhost -U postgres -d my-awesome-api-db +``` + +### Debugging Checklist +| Issue | Check This | +|-------|-----------| +| API not responding | `kubectl get pod -n production`, then `kubectl logs` | +| Migrations not running | `kubectl get eventlistener -n production`, check git push was to `db/migration/` | +| Database connection error | Verify Secret exists: `kubectl get secret -n production -db-credentials` | +| Migration fails with SQL error | Review `*.up.sql` file format and `NNN_description` naming | +| Webhook not triggered | Check Gitea webhook URL is: `http://el--listener..svc.cluster.local:8080` | + +## References + +- [PostgREST Documentation](https://postgrest.org) +- [Helios Operator Documentation](../../../../docs/OPERATOR.md) +- [Backstage Scaffolder Docs](https://backstage.io/docs/features/software-templates) +- [ArgoCD Documentation](https://argo-cd.readthedocs.io/) +- [Tekton Pipelines](https://tekton.dev) diff --git a/apps/portal/examples/postgrest-template/content/gitops/helios-app.yaml b/apps/portal/examples/postgrest-template/content/gitops/helios-app.yaml new file mode 100644 index 0000000..d23375b --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/gitops/helios-app.yaml @@ -0,0 +1,58 @@ +apiVersion: app.helios.io/v1alpha1 +kind: HeliosApp +metadata: + name: ${{ values.name }} + namespace: ${{ values.namespace }} +spec: + # Owner and Git Configuration + owner: ${{ values.owner }} + gitRepo: ${{ values.sourceRepo }} + gitBranch: main + imageRepo: ${{ values.image }} + gitopsRepo: ${{ values.gitopsRepo }} + gitopsPath: ${{ values.name }} + webhookSecret: ${{ values.name }}-webhook-secret + port: ${{ values.port }} + replicas: 1 + triggerType: db-migrate + + components: + - name: ${{ values.name }} + type: web-service + properties: + # Use official PostgREST image from Docker Hub + image: index.docker.io/postgrest/postgrest:latest + port: ${{ values.port }} + env: + # PostgREST Configuration + # PGRST_DB_URI format: postgres://user:password@host:port/database + # The Operator automatically injects DB_HOST, DB_USER, DB_PASS, DB_PORT, DB_NAME from the database trait + # The native operator Phase 0.9 reconciliation builds the PGRST_DB_URI from these and injects it + - name: PGRST_DB_SCHEMA + value: "${{ values.apiSchema }}" + - name: PGRST_DB_ANON_ROLE + value: "${{ values.anonRole }}" + - name: PGRST_JWT_AUDIENCE + value: "${{ values.jwtRole }}" + - name: PGRST_JWT_SECRET + value: "${{ values.jwtSecret }}" + - name: PGRST_MAX_ROWS + value: "1000" + - name: PGRST_LOG_LEVEL + value: "info" + traits: + - type: database + properties: + dbType: postgres + dbName: ${{ values.databaseName or values.name }}-db + port: 5432 + version: "${{ values.databaseVersion or '16' }}" + storage: "1Gi" + - type: service + properties: + port: ${{ values.port }} + protocol: HTTP + ingress: + enabled: true + host: ${{ values.name }}.local + path: / diff --git a/apps/portal/examples/postgrest-template/content/gitops/namespace.yaml b/apps/portal/examples/postgrest-template/content/gitops/namespace.yaml new file mode 100644 index 0000000..b20f342 --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/gitops/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ${{ values.namespace }} + labels: + helios.io/managed-by: helios-operator diff --git a/apps/portal/examples/postgrest-template/content/source/.gitignore b/apps/portal/examples/postgrest-template/content/source/.gitignore new file mode 100644 index 0000000..1a3ab22 --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs/ +*.log + +# Runtime data +pids/ +*.pid + +# Prerequisites +*.dat +*.o + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Build artifacts +dist/ +build/ +*.tar.gz diff --git a/apps/portal/examples/postgrest-template/content/source/Dockerfile b/apps/portal/examples/postgrest-template/content/source/Dockerfile new file mode 100644 index 0000000..a4d5d5d --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/Dockerfile @@ -0,0 +1,23 @@ +# Build a custom PostgREST image with your database schema and configuration +# This extends the official postgrest/postgrest image with your SQL schema files +FROM postgrest/postgrest:v12.2.0 + +# Copy your database schema files +# Place .sql files in the schema/ directory that define your database tables +# Example: schema/01-tables.sql, schema/02-permissions.sql +# These will be loaded into PostgreSQL when the database starts +COPY schema/ /schema/ + +# Copy your PostgREST configuration +# Includes settings like port, database schema to expose, JWT authentication, etc. +COPY postgrestrc.conf /etc/postgrest/postgrestrc.conf + +# Health check to ensure PostgREST is running and ready +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/ || exit 1 + +# Expose the PostgREST API port +EXPOSE 3000 + +# Start PostgREST with your custom configuration +CMD ["postgrest", "/etc/postgrest/postgrestrc.conf"] diff --git a/apps/portal/examples/postgrest-template/content/source/README.md b/apps/portal/examples/postgrest-template/content/source/README.md new file mode 100644 index 0000000..0549843 --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/README.md @@ -0,0 +1,309 @@ +# ${{ values.name }} - PostgREST API + +This is a PostgREST instant REST API service for PostgreSQL. + +## What is PostgREST? + +PostgREST automatically generates a production-ready REST API from any PostgreSQL database schema. You define your data structures in SQL, and PostgREST instantly exposes them via standard HTTP verbs (GET, POST, PUT, DELETE). + +## Quick Start + +### 1. Define Your Schema + +Add your database tables in `schema/` directory: + +```bash +# Example: schema/01-tables.sql +CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + body TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 2. Build Your Custom Image + +The `Dockerfile` builds your custom image with your schema: + +```bash +docker build -t ${{ values.image }}:latest . +docker push ${{ values.image }}:latest +``` + +The Tekton CI/CD pipeline automates this on every commit. + +### 3. Deploy + +```bash +kubectl apply -f gitops/helios-app.yaml +``` + +Helios Operator will: +- Provision PostgreSQL database +- Apply your schema from the image +- Start PostgREST container +- Expose REST API automatically + +## Key Features + +- **Auto-Generated CRUD Operations**: Full REST endpoints from your database schema +- **JWT Authentication**: Secure endpoints with JWT tokens +- **Role-Based Access Control**: Database-enforced permissions +- **OpenAPI Documentation**: Auto-generated API documentation +- **Custom Schema**: Define your tables in SQL + +## Architecture + +``` +Your Source Repo (Docker Image) + ↓ + Tekton CI/CD (build & push image) + ↓ +PostgREST Container (your custom image) + ↓ +PostgreSQL Database (automatically provisioned by Helios Operator) +``` + +## Configuration + +The Helios Operator automatically: + +- Creates a PostgreSQL database +- Applies your schema from `schema/` directory +- Injects `PGRST_DB_URI` environment variable +- Exposes PostgREST on port `${{ values.port }}` + +## Customizing Your Schema + +Edit the SQL files in `schema/` directory: + +- **`01-tables.sql`**: Define your database tables (replace example) +- **`02-permissions.sql`**: Set up roles and access control (customize for your needs) +- Add more `.sql` files as needed (e.g., `03-views.sql`, `04-functions.sql`) + +See [schema/README.md](schema/README.md) for detailed examples. + +## Building and Deploying + +### Step 1: Update Your Schema + +```bash +# Edit example tables to match your data model +vim schema/01-tables.sql + +# Add your permissions and roles +vim schema/02-permissions.sql +``` + +### Step 2: Configure PostgREST (Optional) + +Edit `postgrestrc.conf` to customize: +- API schema to expose +- Authentication method +- CORS settings +- Request limits + +### Step 3: Push to Repository + +```bash +git add -A +git commit -m "Add my database schema" +git push origin main +``` + +Webhook automatically triggers Tekton CI/CD to: +1. Build Docker image with your schema +2. Push image to `${{ values.dockerOrg }}/${{ values.repoName }}` +3. Deploy to Kubernetes via ArgoCD + +### Step 4: Test Your API + +```bash +# Get the API URL (run these in your cluster) +kubectl get ingress -n ${{ values.namespace }} + +# List users +curl https://your-api.example.com/users + +# Create a user +curl -X POST https://your-api.example.com/users \ + -H 'Content-Type: application/json' \ + -d '{"email":"user@example.com","name":"John Doe"}' +``` + +## Generated Repositories + +This template generates **two repositories**: + +### 1. Source Repository (this one) + +Contains your application code: +- `Dockerfile` - Builds custom image with your schema +- `schema/` - SQL files defining your database tables +- `postgrestrc.conf` - PostgREST configuration +- CI/CD pipeline metadata + +When you push changes, Tekton automatically: +1. Builds a new Docker image +2. Runs tests +3. Pushes to `docker.io/${{ values.dockerOrg }}/${{ values.repoName }}` + +### 2. GitOps Repository + +Contains Kubernetes manifests: +- `helios-app.yaml` - HeliosApp CRD (main deployment manifest) +- `argocd-app.yaml` - ArgoCD Application for GitOps sync +- `kustomization.yaml` - Kubernetes bundle configuration + +When ArgoCD syncs, it: +1. Pulls your custom Docker image from the registry +2. Creates PostgreSQL database (via Helios Operator) +3. Deploys PostgREST container +4. Exposes REST API via Ingress + +## Environment Variables + +The Helios Operator automatically sets these for PostgREST: + +- `PGRST_DB_URI` - PostgreSQL connection string (injected as secret) +- `PGRST_DB_SCHEMA` - Schema to expose as REST API (default: `${{ values.apiSchema }}`) +- `PGRST_DB_ANON_ROLE` - Role for unauthenticated requests (default: `${{ values.anonRole }}`) +- `PGRST_JWT_SECRET` - Secret key for JWT verification (from `${{ values.jwtSecret }}`) + +## Complete Workflow + +``` +1. You edit schema/01-tables.sql + ↓ +2. Git push to source repository + ↓ +3. Webhook triggers Tekton CI/CD + ↓ +4. Docker image built with your schema + ↓ +5. Image pushed to docker.io/${{ values.dockerOrg }}/${{ values.repoName }}:latest + ↓ +6. GitOps repository's helios-app.yaml is updated + ↓ +7. ArgoCD detects change and syncs + ↓ +8. Helios Operator creates PostgreSQL + applies schema + ↓ +9. PostgREST container starts with your custom image + ↓ +10. Your REST API is live at https://your-api.example.com/ +``` + +## Next Steps + +1. **Define Your Data Model**: Edit `schema/01-tables.sql` with your tables +2. **Set Permissions**: Configure `schema/02-permissions.sql` for your roles +3. **Configure PostgREST**: Customize `postgrestrc.conf` if needed +4. **Commit & Push**: Changes automatically trigger deployment +5. **Test Your API**: Use the REST endpoints exposed by PostgREST + +## Database Migrations + +For production environments, instead of rebuilding Docker images for every schema change, use the **Database Migration Pipeline**. + +### What is the Migration Pipeline? + +The dedicated Tekton `db-migrate` pipeline enables: +- **Schema changes without rebuilding images** - No Docker rebuild needed +- **Zero-downtime updates** - Uses PostgREST `NOTIFY` to reload schema cache +- **Safe versioning** - golang-migrate tracks applied migrations +- **Controlled deployment** - Trigger manually or on changes to `db/migrations/` + +### Creating Migrations + +1. Create migration files in the `db/migrations/` directory: + ```bash + db/migrations/ + ├── 000001_initial_schema.up.sql + ├── 000001_initial_schema.down.sql + ├── 000002_add_users_table.up.sql + └── 000002_add_users_table.down.sql + ``` + +2. Write your SQL: + ```sql + -- db/migrations/000002_add_users_table.up.sql + CREATE TABLE api.users ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + + GRANT SELECT, INSERT ON api.users TO authenticated_role; + NOTIFY pgrst, 'reload schema'; + ``` + +3. Trigger the migration pipeline: + ```bash + # Manually trigger via kubectl + kubectl create -f - < + - name: app-repo-revision + value: main + - name: database-url + value: + - name: migration-source + value: db/migrations + workspaces: + - name: source + volumeClaimTemplate: + spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 1Gi + EOF + ``` + +For detailed migration guide, see [MIGRATIONS.md](MIGRATIONS.md). + +## Troubleshooting + +### API not responding? +```bash +# Check PostgREST logs +kubectl logs -f deployment/${{ values.name }} -c api + +# Check database connection +kubectl exec -it deployment/${{ values.name }} -- psql "$PGRST_DB_URI" -c "SELECT 1" +``` + +### Database not initialized? +```bash +# Check Operator logs +kubectl logs -f deployment/helios-operator + +# Check database status +kubectl get database -n ${{ values.namespace }} +``` + +### Changes not deployed? +```bash +# Check ArgoCD sync status +argocd app get ${{ values.name }}-gitops + +# Manual sync +argocd app sync ${{ values.name }}-gitops +``` + +## Documentation + +- [PostgREST Official Docs](https://postgrest.org) +- [Helios Operator Guide](../../../../../../docs/OPERATOR.md) +- [Sample Schema](schema/README.md) diff --git a/apps/portal/examples/postgrest-template/content/source/catalog-info.yaml b/apps/portal/examples/postgrest-template/content/source/catalog-info.yaml new file mode 100644 index 0000000..62e4ce8 --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/catalog-info.yaml @@ -0,0 +1,47 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: ${{ values.name }} + annotations: + backstage.io/techdocs-ref: dir:. + backstage.io/kubernetes-id: ${{ values.name }} + backstage.io/kubernetes-label-selector: app.kubernetes.io/name=${{ values.name }} + backstage.io/kubernetes-namespace: default + janus-idp.io/tekton: ${{ values.name }} + tekton.dev/ci-cd: "true" + argocd/app-name: ${{ values.name }}-argocd +spec: + type: service + lifecycle: production + owner: ${{ values.owner }} + provides: + - name: REST API + type: openapi + uri: / + consumesApis: + - database-postgres +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: ${{ values.name }}-api + description: PostgREST Auto-Generated CRUD API +spec: + type: openapi + owner: ${{ values.owner }} + lifecycle: production + definition: | + openapi: 3.0.0 + info: + title: ${{ values.name }} API + version: 1.0.0 + servers: + - url: http://localhost:${{ values.port }} + paths: + /: + get: + summary: API Health Check + responses: + '200': + description: API is operational + x-service-binding: true diff --git a/apps/portal/examples/postgrest-template/content/source/db/migrations/.gitkeep b/apps/portal/examples/postgrest-template/content/source/db/migrations/.gitkeep new file mode 100644 index 0000000..05b9dea --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/db/migrations/.gitkeep @@ -0,0 +1,14 @@ +# Database migrations directory +# +# Place your golang-migrate migration files here following this naming convention: +# +# {VERSION}_{NAME}.up.sql - Apply migration +# {VERSION}_{NAME}.down.sql - Rollback migration +# +# Example: +# 000001_initial_schema.up.sql +# 000001_initial_schema.down.sql +# 000002_add_users_table.up.sql +# 000002_add_users_table.down.sql +# +# See ../MIGRATIONS.md for detailed guide. diff --git a/apps/portal/examples/postgrest-template/content/source/db/migrations/000001_initial_schema.down.sql b/apps/portal/examples/postgrest-template/content/source/db/migrations/000001_initial_schema.down.sql new file mode 100644 index 0000000..8d0a81d --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/db/migrations/000001_initial_schema.down.sql @@ -0,0 +1,26 @@ +-- Rollback initial schema setup + +-- ===================================================== +-- Drop tables +-- ===================================================== + +DROP TABLE IF EXISTS api.items CASCADE; + +-- ===================================================== +-- Drop roles (only if they exist and no other tables depend on them) +-- ===================================================== + +-- Note: In production, be careful dropping roles - they may have permissions +-- on other objects. This is safe for the example template. +DO $$ BEGIN + DROP ROLE IF EXISTS anon; + DROP ROLE IF EXISTS authenticated; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'Roles may still be in use: %', SQLERRM; +END $$; + +-- ===================================================== +-- Drop schema +-- ===================================================== + +DROP SCHEMA IF EXISTS api CASCADE; diff --git a/apps/portal/examples/postgrest-template/content/source/db/migrations/000001_initial_schema.up.sql b/apps/portal/examples/postgrest-template/content/source/db/migrations/000001_initial_schema.up.sql new file mode 100644 index 0000000..1f38f41 --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/db/migrations/000001_initial_schema.up.sql @@ -0,0 +1,66 @@ +-- Initial schema setup for PostgREST API +-- This migration creates the foundational tables and roles for the API + +-- ===================================================== +-- Create the API schema +-- ===================================================== +CREATE SCHEMA IF NOT EXISTS api; +COMMENT ON SCHEMA api IS 'Public API schema exposed by PostgREST'; + +-- ===================================================== +-- Create database roles +-- ===================================================== + +-- Role for unauthenticated API access +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + COMMENT ON ROLE anon IS 'Role for anonymous (unauthenticated) API requests'; + END IF; +END $$; + +-- Role for authenticated API access +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + COMMENT ON ROLE authenticated IS 'Role for authenticated API requests'; + END IF; +END $$; + +-- ===================================================== +-- Schema permissions +-- ===================================================== + +-- Grant access to the API schema +GRANT USAGE ON SCHEMA api TO anon; +GRANT USAGE ON SCHEMA api TO authenticated; + +-- ===================================================== +-- Example: Create a simple table +-- ===================================================== + +CREATE TABLE IF NOT EXISTS api.items ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE api.items IS 'Example table exposed by the PostgREST API'; +COMMENT ON COLUMN api.items.id IS 'Unique identifier'; +COMMENT ON COLUMN api.items.title IS 'Item title'; +COMMENT ON COLUMN api.items.description IS 'Item description'; + +-- Grant permissions on tables +GRANT SELECT, INSERT, UPDATE, DELETE ON api.items TO authenticated; +GRANT SELECT ON api.items TO anon; + +-- Grant sequence access for INSERT operations +GRANT USAGE, SELECT ON SEQUENCE api.items_id_seq TO authenticated; +GRANT USAGE, SELECT ON SEQUENCE api.items_id_seq TO anon; + +-- ===================================================== +-- Notify PostgREST to reload the schema +-- ===================================================== +NOTIFY pgrst, 'reload schema'; diff --git a/apps/portal/examples/postgrest-template/content/source/postgrestrc.conf b/apps/portal/examples/postgrest-template/content/source/postgrestrc.conf new file mode 100644 index 0000000..5d35e29 --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/postgrestrc.conf @@ -0,0 +1,33 @@ +# PostgREST Configuration File +# The PGRST_DB_URI is injected by the Helios Operator as an environment variable + +# Database connection string (injected via PGRST_DB_URI env var) +# db-uri = "postgres://user:pass@localhost/dbname" + +# Port on which to serve HTTP requests +server-port = ${{ values.port }} + +# PostgreSQL schema to expose to clients +db-schema = "${{ values.apiSchema }}" + +# Role to use for unauthenticated requests +# Omit or set to empty string to disallow anonymous access +db-anon-role = "${{ values.anonRole }}" + +# JWT Configuration (optional, for authenticated requests) +# Role to use for authenticated requests +jwt-aud = "${{ values.jwtRole }}" + +# This key will be used to verify JWTs; it can be a symmetric key or asymmetric public key +# For now, we disable JWT verification; clients can enable it by providing the secret +# jwt-secret = "your-secret-here" + +# Request settings +max-rows = 1000 + +# Logging +log-level = "info" + +# Connection pool settings (optional) +db-pool = 10 +db-pool-timeout = 10 diff --git a/apps/portal/examples/postgrest-template/content/source/schema/01-tables.sql b/apps/portal/examples/postgrest-template/content/source/schema/01-tables.sql new file mode 100644 index 0000000..3989497 --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/schema/01-tables.sql @@ -0,0 +1,28 @@ +-- Example: Create basic tables +-- Replace this with your own schema + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + body TEXT, + published BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- PostgREST automatically creates REST endpoints for these tables: +-- GET /users - List all users +-- POST /users - Create a new user +-- GET /users?id=eq.1 - Get user with id=1 +-- PATCH /users?id=eq.1 - Update user with id=1 +-- DELETE /users?id=eq.1 - Delete user with id=1 +-- +-- Same for /posts, /posts/{id}, etc. diff --git a/apps/portal/examples/postgrest-template/content/source/schema/02-permissions.sql b/apps/portal/examples/postgrest-template/content/source/schema/02-permissions.sql new file mode 100644 index 0000000..489df7a --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/schema/02-permissions.sql @@ -0,0 +1,31 @@ +-- Example: Set up roles and permissions +-- Customize this based on your authentication requirements + +-- Create roles (if not already created by Helios Operator) +DO $$ +BEGIN + CREATE ROLE anon NOLOGIN; +EXCEPTION WHEN DUPLICATE_OBJECT THEN + NULL; +END $$; + +DO $$ +BEGIN + CREATE ROLE authenticated NOLOGIN; +EXCEPTION WHEN DUPLICATE_OBJECT THEN + NULL; +END $$; + +-- Remove old grants (if any) +REVOKE ALL ON users, posts FROM anon, authenticated; + +-- Grant SELECT access to anonymous users +GRANT SELECT ON users, posts TO anon; + +-- Grant CRUD access to authenticated users +GRANT SELECT, INSERT, UPDATE, DELETE ON users, posts TO authenticated; + +-- Allow users to update their own records (example) +-- Note: PostgREST also supports row-level security for fine-grained control +CREATE POLICY user_self_update ON users FOR UPDATE + USING (id = current_user_id()); diff --git a/apps/portal/examples/postgrest-template/content/source/schema/README.md b/apps/portal/examples/postgrest-template/content/source/schema/README.md new file mode 100644 index 0000000..ede1967 --- /dev/null +++ b/apps/portal/examples/postgrest-template/content/source/schema/README.md @@ -0,0 +1,124 @@ +# Database Schema + +This directory contains SQL files that define your database schema. Add your database tables, views, and permissions here. + +## How It Works + +1. **Add SQL Files**: Create `.sql` files in this directory (e.g., `01-tables.sql`, `02-permissions.sql`) +2. **Automatic Loading**: When the Docker image is built, these files are copied to the container +3. **Schema Applied**: When PostgreSQL starts (via Helios Operator), the Operator applies your schema +4. **REST API Generated**: PostgREST automatically exposes your tables as REST endpoints + +## File Structure + +Use numbered prefixes to control execution order: + +``` +schema/ + 01-tables.sql # CREATE TABLE statements + 02-permissions.sql # GRANT statements for roles + 03-views.sql # CREATE VIEW statements +``` + +## Example: 01-tables.sql + +```sql +-- Create a users table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Create a posts table +CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + body TEXT, + published BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +## Example: 02-permissions.sql + +```sql +-- Create roles for authentication +CREATE ROLE anon NOLOGIN; +CREATE ROLE authenticated NOLOGIN IN ROLE anon; + +-- Grant read access to anon role +GRANT SELECT ON users, posts TO anon; + +-- Grant full access to authenticated role +GRANT ALL ON users, posts TO authenticated; +``` + +## How PostgREST Exposes Your Schema + +Once your schema is loaded, PostgREST automatically creates REST endpoints: + +```bash +# List all users +GET /users + +# Create a new user +POST /users +Content-Type: application/json +{ "email": "user@example.com", "name": "John Doe" } + +# Get a specific user +GET /users?id=eq.1 + +# Update a user +PATCH /users?id=eq.1 +{ "name": "Jane Doe" } + +# Delete a user +DELETE /users?id=eq.1 +``` + +## Authentication + +Set up JWT authentication in your PostgREST configuration (`postgrestrc.conf`): + +```ini +db-uri = "postgres://..." +db-schema = "public" +db-anon-role = "anon" +jwt-secret = "your-secret-key" +jwt-claim-check-aud = false +jwt-claim-check-sub = false +``` + +Then reference this: + +```bash +# Include JWT token in Authorization header +curl -H "Authorization: Bearer $JWT_TOKEN" \ + http://api.example.com/posts +``` + +## Best Practices + +1. **Use descriptive names**: `users`, `posts`, `comments` (plural) +2. **Add timestamps**: `created_at`, `updated_at` for audit trails +3. **Use constraints**: NOT NULL, UNIQUE, FOREIGN KEY for data integrity +4. **Define roles early**: Separate `anon` and `authenticated` for different access levels +5. **Number your files**: `01-`, `02-`, `03-` to ensure correct load order +6. **Keep it simple**: Start with basic CRUD, add views and functions later + +## Testing Locally + +```bash +docker build -t my-api:latest . +docker run -e PGRST_DB_URI="postgres://user:pass@localhost/mydb" my-api:latest +``` + +## More Information + +- [PostgREST Documentation](https://postgrest.org/en/stable/) +- [PostgreSQL Documentation](https://www.postgresql.org/docs/) +- [Helios Operator Documentation](../../docs/OPERATOR.md) diff --git a/apps/portal/examples/postgrest-template/template.yaml b/apps/portal/examples/postgrest-template/template.yaml new file mode 100644 index 0000000..35e5da1 --- /dev/null +++ b/apps/portal/examples/postgrest-template/template.yaml @@ -0,0 +1,229 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: postgrest-template + title: PostgREST API Template + description: >- + Scaffolds a PostgREST instant REST API over a PostgreSQL database. + PostgREST automatically generates CRUD endpoints from your database schema. + The Helios Operator automatically provisions the database and injects + the connection string (PGRST_DB_URI) into the PostgREST pod. +spec: + owner: user:guest + type: service + + parameters: + - title: Component Information + required: + - name + - port + - dockerOrg + - repoName + properties: + name: + title: Name + type: string + description: Unique name of the PostgREST API service + ui:autofocus: true + port: + title: API Port + type: number + description: The port PostgREST listens on + default: 3000 + dockerOrg: + title: Docker Registry Org/User + type: string + description: Your Docker Hub username or Organization + repoName: + title: Docker Repository Name + type: string + description: The name of the Docker repository (e.g. my-api) + + - title: PostgREST Configuration + properties: + apiSchema: + title: API Schema + type: string + description: PostgreSQL schema to expose as REST API (e.g. public, api) + default: public + jwtSecret: + title: JWT Secret + type: string + description: Secret for signing JWT tokens (32-64 characters required for PostgREST) + minLength: 32 + maxLength: 64 + ui:field: Secret + jwtRole: + title: JWT Role + type: string + description: Default role claim in JWT tokens + default: authenticated + anonRole: + title: Anonymous Role + type: string + description: Role for unauthenticated requests (or leave empty to disable) + default: anon + + - title: Database Configuration + properties: + databaseConfig: + title: Database Settings + type: object + ui:field: DatabasePicker + + - title: Repository & Webhook + required: + - repoUrl + properties: + repoUrl: + title: Source Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - localhost:3030 + namespace: + title: Kubernetes Namespace + type: string + description: Namespace where the service will be deployed + default: default + + - title: Optional Extras (Local Convenience) + properties: + registerToCatalog: + title: Register component in catalog + type: boolean + default: false + sendNotification: + title: Send Backstage notification + type: boolean + default: false + + steps: + # 1. Source Code (config files + Dockerfile) + - id: fetch-source + name: Fetch Source Code + action: fetch:template + input: + url: ./content/source + targetPath: ./source + values: + name: ${{ parameters.repoName }} + owner: ${{ user.entity.metadata.name or 'guest' }} + port: ${{ parameters.port }} + description: "PostgREST API: ${{ parameters.name }}" + image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }} + apiSchema: ${{ parameters.apiSchema }} + jwtSecret: ${{ secrets.jwtSecret }} + jwtRole: ${{ parameters.jwtRole }} + anonRole: ${{ parameters.anonRole }} + + - id: publish-source + name: Publish Source Code + action: publish:gitea + input: + description: PostgREST Configuration for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} + sourcePath: ./source + repoVisibility: public + + - id: create-webhook + name: Create Webhook for DB Migration Trigger + action: gitea:create-webhook + input: + repoUrl: ${{ parameters.repoUrl }} + webhookUrl: http://el-${{ parameters.repoName }}-db-migrate-listener.${{ parameters.namespace }}.svc.cluster.local:8080 + webhookSecret: ${{ parameters.repoName }} + events: + - push + + # 2. GitOps + - id: fetch-gitops + name: Fetch GitOps Manifests + action: fetch:template + input: + url: ./content/gitops + targetPath: ./gitops + values: + name: ${{ parameters.repoName }} + image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }} + dockerOrg: ${{ parameters.dockerOrg }} + repoName: ${{ parameters.repoName }} + port: ${{ parameters.port }} + namespace: ${{ parameters.namespace }} + databaseType: ${{ parameters.databaseConfig.dbType }} + databaseName: ${{ parameters.databaseConfig.dbName }} + databaseVersion: ${{ parameters.databaseConfig.version or '16' }} + apiSchema: ${{ parameters.apiSchema }} + jwtSecret: ${{ secrets.jwtSecret }} + jwtRole: ${{ parameters.jwtRole }} + anonRole: ${{ parameters.anonRole }} + owner: ${{ user.entity.metadata.name or 'guest' }} + sourceRepo: ${{ steps['publish-source'].output.remoteUrl }} + gitopsRepo: ${{ steps['publish-source'].output.remoteUrl | replace(".git", "") }}-gitops + + - id: publish-gitops + name: Publish GitOps Manifests + action: publish:gitea + input: + description: GitOps Manifests for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }}-gitops + sourcePath: ./gitops + repoVisibility: public + + # 3. Apply namespace to cluster + - id: apply-namespace + name: Create Kubernetes Namespace + action: kubernetes:apply + input: + manifestPath: ./gitops/namespace.yaml + + # 4. Create webhook secret in cluster + - id: create-webhook-secret + name: Create Kubernetes Webhook Secret + action: kubernetes:create-secret + input: + namespace: ${{ parameters.namespace }} + secretName: ${{ parameters.repoName }}-webhook-secret + data: + # Tekton Triggers interceptor expects the shared secret under key `secret`. + secret: ${{ parameters.repoName }} + # Keep `secretToken` for compatibility with any legacy consumers. + secretToken: ${{ parameters.repoName }} + + # 5. Deploy HeliosApp to cluster + - id: apply-heliosapp + name: Apply HeliosApp to Cluster + action: kubernetes:apply + input: + manifestPath: ./gitops/helios-app.yaml + + # 6. Registration + - id: register + name: Register Component + action: catalog:register + if: '{{ parameters.registerToCatalog }}' + input: + repoContentsUrl: ${{ steps['publish-source'].output.repoContentsUrl }} + catalogInfoPath: 'catalog-info.yaml' + + - id: notify + name: Notify User + action: notification:send + if: '{{ parameters.sendNotification }}' + input: + recipients: entity + entityRefs: + - user:default/guest + title: 'PostgREST API Template Executed' + info: 'Your PostgREST instant REST API has been scaffolded with automatic CRUD endpoints!' + + output: + links: + - title: Source Repository + url: ${{ steps['publish-source'].output.remoteUrl }} + - title: GitOps Repository + url: ${{ steps['publish-gitops'].output.remoteUrl }} + - title: Open in Catalog + icon: catalog + entityRef: ${{ steps['register'].output.entityRef }} diff --git a/apps/portal/examples/postgrest-template/validate.sh b/apps/portal/examples/postgrest-template/validate.sh new file mode 100755 index 0000000..1fb964d --- /dev/null +++ b/apps/portal/examples/postgrest-template/validate.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# PostgREST Template E2E Validation Script +# This script validates that the template is correctly structured + +set -e + +TEMPLATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "=== PostgREST Template E2E Validation ===" +echo "" +echo "Template Directory: $TEMPLATE_DIR" +echo "" + +# Test 1: Check file structure +echo "Test 1: Verifying directory structure..." +required_files=( + "template.yaml" + "content/source/catalog-info.yaml" + "content/source/Dockerfile" + "content/source/postgrestrc.conf" + "content/source/README.md" + "content/source/.gitignore" + "content/gitops/helios-app.yaml" +) + +all_exist=true +for file in "${required_files[@]}"; do + if [ -f "$TEMPLATE_DIR/$file" ]; then + echo " ✓ $file" + else + echo " ✗ Missing: $file" + all_exist=false + fi +done + +if [ "$all_exist" = false ]; then + echo "" + echo "✗ Some required files are missing" + exit 1 +fi + +# Test 2: Validate YAML syntax of source templates +echo "" +echo "Test 2: Validating YAML syntax..." + +# Ensure PyYAML is available +if ! python3 -c "import yaml" 2>/dev/null; then + echo " Installing PyYAML..." + python3 -m pip install -q pyyaml || { echo " ✗ Failed to install PyYAML"; exit 1; } +fi + +TEMPLATE_DIR="$TEMPLATE_DIR" python3 << 'PYTHON_EOF' +import yaml +import os +import sys + +template_dir = os.environ.get('TEMPLATE_DIR') +if not template_dir: + template_dir = os.getcwd() + +yaml_files = [ + 'template.yaml', + 'content/source/catalog-info.yaml', + 'content/gitops/helios-app.yaml', +] + +errors = [] +for yaml_file in sorted(yaml_files): + full_path = os.path.join(template_dir, yaml_file) + try: + with open(full_path, 'r') as f: + list(yaml.safe_load_all(f)) + print(f" ✓ {yaml_file}") + except Exception as e: + errors.append(f" ✗ {yaml_file}: {e}") + +for error in errors: + print(error) + +if errors: + sys.exit(1) +PYTHON_EOF + +# Test 3: Verify template contains required Backstage scaffold fields +echo "" +echo "Test 3: Verifying Backstage scaffolder structure..." + +check_field() { + local file=$1 + local field=$2 + local description=$3 + + if grep -q "$field" "$TEMPLATE_DIR/$file" 2>/dev/null; then + echo " ✓ Found $description" + return 0 + else + echo " ✗ Missing $description in $file" + return 1 + fi +} + +# Check main template +check_field "template.yaml" "kind: Template" "Template kind" +check_field "template.yaml" "parameters:" "Parameters section" +check_field "template.yaml" "steps:" "Steps section" +check_field "template.yaml" "publish:gitea" "Gitea publish action" + +# Check HeliosApp includes database trait +echo "" +echo "Test 4: Verifying HeliosApp CRD structure..." +check_field "content/gitops/helios-app.yaml" "kind: HeliosApp" "HeliosApp kind" +check_field "content/gitops/helios-app.yaml" "type: database" "Database trait" +check_field "content/gitops/helios-app.yaml" "dbType: postgres" "Postgres configuration" + + + +# Check that PostgREST configuration is present +echo "" +echo "Test 5: Verifying PostgREST-specific configuration..." +check_field "content/source/postgrestrc.conf" "db-schema" "PostgREST schema config" +check_field "content/source/postgrestrc.conf" "db-anon-role" "PostgREST anonymous role" +check_field "content/source/postgrestrc.conf" "server-port" "PostgREST server port config" +check_field "content/source/Dockerfile" "postgrest/postgrest" "Official PostgREST image" + +# Check database migrations structure +echo "" +echo "Test 6: Verifying database migration structure..." +if [ -d "$TEMPLATE_DIR/content/source/db/migrations" ]; then + echo " ✓ db/migrations directory exists" + if [ -f "$TEMPLATE_DIR/content/source/db/migrations/000001_initial_schema.up.sql" ]; then + echo " ✓ Sample migration file present" + else + echo " ⚠ No sample migrations found (this is OK, but users should add them)" + fi +else + echo " ✗ db/migrations directory missing" + exit 1 +fi + +# Check migration documentation +if [ -f "$TEMPLATE_DIR/content/source/MIGRATIONS.md" ]; then + echo " ✓ Migration documentation present" +else + echo " ⚠ MIGRATIONS.md not found" +fi + +# Check that PGRST_DB_URI is referenced +echo "" +echo "Test 7: Verifying PGRST_DB_URI integration..." +check_field "content/source/README.md" "PGRST_DB_URI" "PGRST_DB_URI documentation" +check_field "content/gitops/helios-app.yaml" "database" "Database trait for credential injection" + +# Summary +echo "" +echo "=== Validation Results ===" +echo "✓ Template structure is valid" +echo "✓ YAML syntax is correct" +echo "✓ HeliosApp CRD correctly configured" +echo "✓ PostgREST configuration present" +echo "✓ Database migrations configured" +echo "✓ PGRST_DB_URI integration configured" +echo "" +echo "✓ PostgREST template is ready for deployment!" +echo "" +echo "Next steps:" +echo "1. Deploy to Helios Platform cluster" +echo "2. Register template in Backstage" +echo "3. Users can scaffold PostgREST services via UI" +echo "4. Users can create database migrations in db/migrations/" +echo "" + diff --git a/apps/portal/examples/spring-boot-template/content/gitops/helios-app.yaml b/apps/portal/examples/spring-boot-template/content/gitops/helios-app.yaml new file mode 100644 index 0000000..b2e83ce --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/gitops/helios-app.yaml @@ -0,0 +1,34 @@ +apiVersion: app.helios.io/v1alpha1 +kind: HeliosApp +metadata: + name: ${{ values.name }} + namespace: default +spec: + owner: ${{ values.owner }} + description: "Spring Boot service: ${{ values.name }}" + gitRepo: ${{ values.sourceRepo }} + gitBranch: main + imageRepo: ${{ values.image }} + gitopsRepo: ${{ values.gitopsRepo }} + gitopsPath: ${{ values.name }} + pipelineName: from-code-to-cluster + webhookSecret: git-credentials-${{ values.name }} + port: ${{ values.port }} + testCommand: "gradle test" + components: + - name: ${{ values.name }} + type: web-service + properties: + image: ${{ values.image }}:latest + port: ${{ values.port }} + replicas: 1 + traits: + - type: service + properties: + port: ${{ values.port }} + - type: database + properties: + dbType: ${{ values.databaseType | default("postgres") }} + dbName: ${{ values.name | replace('-', '_') }}_db + version: "16" + storage: "1Gi" diff --git a/apps/portal/examples/spring-boot-template/content/source/.env.example b/apps/portal/examples/spring-boot-template/content/source/.env.example new file mode 100644 index 0000000..ecf85c5 --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/.env.example @@ -0,0 +1,9 @@ +# Database credentials (injected by Helios Operator in Kubernetes) +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=${{ values.name | replace('-', '_') }}_db +DB_USER=postgres +DB_PASS=postgres + +# Application +SERVER_PORT=${{ values.port }} diff --git a/apps/portal/examples/spring-boot-template/content/source/.gitignore b/apps/portal/examples/spring-boot-template/content/source/.gitignore new file mode 100644 index 0000000..b52d75f --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/.gitignore @@ -0,0 +1,27 @@ +# ────────────────────────────────────────────────────── +# .gitignore — Spring Boot + Gradle +# ────────────────────────────────────────────────────── + +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.project +.classpath +.settings/ +out/ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env diff --git a/apps/portal/examples/spring-boot-template/content/source/Dockerfile b/apps/portal/examples/spring-boot-template/content/source/Dockerfile new file mode 100644 index 0000000..9c9218e --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/Dockerfile @@ -0,0 +1,59 @@ +# ────────────────────────────────────────────────────────── +# Multi-stage Dockerfile for Spring Boot (Gradle) +# ────────────────────────────────────────────────────────── +# Stage 1 — Build +# Uses the official Gradle image with JDK 21. We copy sources +# into the container and run a full Gradle build. The resulting +# fat JAR is extracted for the next stage. +# +# Stage 2 — Runtime +# Uses a minimal Eclipse Temurin JRE-only Alpine image to keep +# the final image small (~200 MB vs ~800 MB with a full JDK). +# +# Kaniko Compatibility +# This Dockerfile avoids features that are problematic for +# Kaniko (e.g. mounting secrets, multi-platform builds). +# The build is fully self-contained. +# ────────────────────────────────────────────────────────── + +# ---- Build Stage ---- +FROM gradle:8.13-jdk21 AS builder + +WORKDIR /workspace + +# Copy Gradle wrapper & build scripts first to leverage Docker +# layer caching — dependencies are only re-downloaded when these +# files change. +COPY build.gradle settings.gradle ./ +COPY gradle ./gradle/ + +# Download dependencies in a separate layer for caching +RUN gradle dependencies --no-daemon || true + +# Copy source code and build +COPY src ./src/ +RUN gradle bootJar --no-daemon -x test + +# ---- Production Stage ---- +FROM eclipse-temurin:21-jre-alpine AS production + +# Create a non-root user for security +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +WORKDIR /app + +# Copy the fat JAR from the builder stage +COPY --from=builder /workspace/build/libs/app.jar ./app.jar + +# Switch to non-root user +USER appuser + +EXPOSE ${{ values.port }} + +# Use exec form so the JVM receives signals (SIGTERM) correctly +# for graceful shutdown in Kubernetes. +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", "app.jar"] diff --git a/apps/portal/examples/spring-boot-template/content/source/build.gradle b/apps/portal/examples/spring-boot-template/content/source/build.gradle new file mode 100644 index 0000000..cc8b32c --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.5' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.helios' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // Spring Boot starters + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // PostgreSQL driver + runtimeOnly 'org.postgresql:postgresql' + + // Testing + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' +} + +tasks.named('test') { + useJUnitPlatform() +} + +// Produce a reproducible, plain JAR name for the Dockerfile +bootJar { + archiveFileName = 'app.jar' +} diff --git a/apps/portal/examples/spring-boot-template/content/source/catalog-info.yaml b/apps/portal/examples/spring-boot-template/content/source/catalog-info.yaml new file mode 100644 index 0000000..3043aac --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/catalog-info.yaml @@ -0,0 +1,17 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: ${{ values.name }} + description: ${{ values.description | dump }} + annotations: + backstage.io/techdocs-ref: dir:. + backstage.io/kubernetes-id: ${{ values.name }} + backstage.io/kubernetes-label-selector: app.kubernetes.io/name=${{ values.name }} + backstage.io/kubernetes-namespace: default + janus-idp.io/tekton: ${{ values.name }} + tekton.dev/ci-cd: "true" + argocd/app-name: ${{ values.name }}-argocd +spec: + type: service + lifecycle: production + owner: ${{ values.owner }} diff --git a/apps/portal/examples/spring-boot-template/content/source/docker-compose.yml b/apps/portal/examples/spring-boot-template/content/source/docker-compose.yml new file mode 100644 index 0000000..352aa8b --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/docker-compose.yml @@ -0,0 +1,35 @@ +# ────────────────────────────────────────────────────────── +# docker-compose.yml — Local Development Environment +# ────────────────────────────────────────────────────────── +# Usage: +# docker compose up -d # Start PostgreSQL +# ./gradlew bootRun # Start the Spring Boot app +# docker compose down -v # Tear down (remove volumes) +# +# This file starts ONLY the database. The Spring Boot app is +# expected to run on the host (via IDE or Gradle) so that hot +# reload and debugging work seamlessly. +# ────────────────────────────────────────────────────────── + +services: + postgres: + image: postgres:16-alpine + container_name: ${{ values.name }}-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${{ values.name | replace('-', '_') }}_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + pgdata: + driver: local diff --git a/apps/portal/examples/spring-boot-template/content/source/gradle/wrapper/gradle-wrapper.properties b/apps/portal/examples/spring-boot-template/content/source/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apps/portal/examples/spring-boot-template/content/source/settings.gradle b/apps/portal/examples/spring-boot-template/content/source/settings.gradle new file mode 100644 index 0000000..c0474ca --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/settings.gradle @@ -0,0 +1 @@ +rootProject.name = '${{ values.name }}' diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/Application.java b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/Application.java new file mode 100644 index 0000000..81cacc0 --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/Application.java @@ -0,0 +1,22 @@ +package com.helios.app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Entry point for the ${{ values.name }} Spring Boot application. + * + *

The {@code @SpringBootApplication} annotation enables: + *

    + *
  • Component scanning within the {@code com.helios.app} package
  • + *
  • Auto-configuration of Spring Data JPA, Actuator, etc.
  • + *
  • Property source resolution from {@code application.yml}
  • + *
+ */ +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/controller/AppController.java b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/controller/AppController.java new file mode 100644 index 0000000..fd75ff6 --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/controller/AppController.java @@ -0,0 +1,35 @@ +package com.helios.app.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.Map; + +/** + * Root controller providing basic health-check and greeting endpoints. + * + *

These endpoints serve two purposes: + *

    + *
  1. Give developers immediate visual feedback that the app is running.
  2. + *
  3. Provide a lightweight HTTP check independent of Spring Actuator + * for quick smoke-testing during CI/CD.
  4. + *
+ */ +@RestController +public class AppController { + + @GetMapping("/") + public ResponseEntity> hello() { + return ResponseEntity.ok(Map.of( + "message", "Hello from ${{ values.name }}!", + "timestamp", Instant.now().toString() + )); + } + + @GetMapping("/health") + public ResponseEntity> health() { + return ResponseEntity.ok(Map.of("status", "ok")); + } +} diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/entity/SampleItem.java b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/entity/SampleItem.java new file mode 100644 index 0000000..2cdff73 --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/entity/SampleItem.java @@ -0,0 +1,71 @@ +package com.helios.app.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * Sample JPA entity demonstrating database connectivity. + * + *

This entity exists so the scaffolded project proves end-to-end + * database integration out of the box. Developers should replace or + * extend it with their domain models.

+ */ +@Entity +@Table(name = "sample_items") +public class SampleItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + // ── Constructors ─────────────────────────────────── + + protected SampleItem() { + // JPA requires a no-arg constructor + } + + public SampleItem(String name, String description) { + this.name = name; + this.description = description; + } + + // ── Getters & Setters ────────────────────────────── + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/repository/SampleItemRepository.java b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/repository/SampleItemRepository.java new file mode 100644 index 0000000..abd2aa0 --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/repository/SampleItemRepository.java @@ -0,0 +1,15 @@ +package com.helios.app.repository; + +import com.helios.app.entity.SampleItem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for {@link SampleItem}. + * + *

Provides CRUD operations and query derivation. Developers can add + * custom query methods following Spring Data naming conventions.

+ */ +@Repository +public interface SampleItemRepository extends JpaRepository { +} diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/resources/application.yml b/apps/portal/examples/spring-boot-template/content/source/src/main/resources/application.yml new file mode 100644 index 0000000..b46790c --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/main/resources/application.yml @@ -0,0 +1,64 @@ +# ────────────────────────────────────────────────────────── +# Application Configuration +# ────────────────────────────────────────────────────────── +spring: + application: + name: ${{ values.name }} + + # ── JPA / Hibernate ─────────────────────────────────── + jpa: + # Validate schema against entities on startup; migrations + # should be handled by Flyway / Liquibase in production. + hibernate: + ddl-auto: update + show-sql: false + open-in-view: false + properties: + hibernate: + format_sql: true + jdbc: + time_zone: UTC + + # ── DataSource ──────────────────────────────────────── + # Environment variables are injected by the Helios Operator + # when deployed on Kubernetes. For local development the + # values fall back to sensible defaults that match the + # docker-compose.yml shipped with this project. + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:${{ values.name | replace('-', '_') }}_db} + username: ${DB_USER:postgres} + password: ${DB_PASS:postgres} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + idle-timeout: 30000 + connection-timeout: 20000 + +# ── Server ────────────────────────────────────────────── +server: + port: ${SERVER_PORT:${{ values.port }}} + +# ── Actuator (health/readiness probes for Kubernetes) ── +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: when-authorized + probes: + enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true + +# ── Logging ───────────────────────────────────────────── +logging: + level: + root: INFO + com.helios: DEBUG + org.hibernate.SQL: WARN diff --git a/apps/portal/examples/spring-boot-template/content/source/src/test/java/com/helios/app/ApplicationTests.java b/apps/portal/examples/spring-boot-template/content/source/src/test/java/com/helios/app/ApplicationTests.java new file mode 100644 index 0000000..226c9ed --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/test/java/com/helios/app/ApplicationTests.java @@ -0,0 +1,22 @@ +package com.helios.app; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Smoke test ensuring the Spring application context loads successfully. + * + *

Uses an in-memory H2 database (via the {@code test} profile) so + * the test suite runs without an external PostgreSQL instance — critical + * for CI pipelines (Tekton) where a DB may not yet be provisioned.

+ */ +@SpringBootTest +@ActiveProfiles("test") +class ApplicationTests { + + @Test + void contextLoads() { + // If the context fails to load, this test will fail. + } +} diff --git a/apps/portal/examples/spring-boot-template/content/source/src/test/resources/application-test.yml b/apps/portal/examples/spring-boot-template/content/source/src/test/resources/application-test.yml new file mode 100644 index 0000000..7a0ed2a --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/test/resources/application-test.yml @@ -0,0 +1,14 @@ +# ────────────────────────────────────────────────────────── +# Test Profile — used by CI/CD pipelines (Tekton) and local +# test runs where a real PostgreSQL instance is not available. +# ────────────────────────────────────────────────────────── +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect diff --git a/apps/portal/examples/spring-boot-template/template.yaml b/apps/portal/examples/spring-boot-template/template.yaml new file mode 100644 index 0000000..b6d453a --- /dev/null +++ b/apps/portal/examples/spring-boot-template/template.yaml @@ -0,0 +1,183 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: spring-boot-template + title: Java Spring Boot Template (Database-backed) + description: >- + Scaffolds a standard Java Spring Boot application (Gradle) with a PostgreSQL database. + The Helios Operator automatically provisions the database and injects + credentials (DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS) into the backend pod. +spec: + owner: user:guest + type: service + + parameters: + - title: Component Information + required: + - name + - port + - dockerOrg + - repoName + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + port: + title: Port + type: number + description: The port the Spring Boot app listens on + default: 8080 + dockerOrg: + title: Docker Registry Org/User + type: string + description: Your Docker Hub username or Organization + repoName: + title: Docker Repository Name + type: string + description: The name of the Docker repository (e.g. my-service) + + - title: Database Configuration + properties: + databaseConfig: + title: Database Settings + type: object + ui:field: DatabasePicker + + - title: Repository & Webhook + required: + - repoUrl + properties: + repoUrl: + title: Source Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - localhost:3030 + - title: Optional Extras (Local Convenience) + properties: + registerToCatalog: + title: Register component in catalog + type: boolean + default: false + sendNotification: + title: Send Backstage notification + type: boolean + default: false + + steps: + # 1. Source Code + - id: fetch-source + name: Fetch Source Code + action: fetch:template + input: + url: ./content/source + targetPath: ./source + values: + # Use repoName as the app identifier to keep it Kubernetes/URL safe. + name: ${{ parameters.repoName }} + owner: ${{ user.entity.metadata.name or 'guest' }} + port: ${{ parameters.port }} + description: "Spring Boot service: ${{ parameters.name }}" + image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }} + + - id: publish-source + name: Publish Source Code + action: publish:gitea + input: + description: Source Code for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} + sourcePath: ./source + repoVisibility: public + + - id: create-webhook + name: Create Webhook + action: gitea:create-webhook + input: + repoUrl: ${{ parameters.repoUrl }} + # Tekton EventListener address is the service root. + webhookUrl: http://el-${{ parameters.repoName }}-listener.default.svc.cluster.local:8080 + # Deterministic secret so users don't have to provide one. + # Must match the Kubernetes secret value created below. + webhookSecret: ${{ parameters.repoName }} + events: + - push + + # 2. GitOps + - id: fetch-gitops + name: Fetch GitOps Manifests + action: fetch:template + input: + url: ./content/gitops + targetPath: ./gitops + values: + name: ${{ parameters.repoName }} + image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }} + dockerOrg: ${{ parameters.dockerOrg }} + repoName: ${{ parameters.repoName }} + port: ${{ parameters.port }} + databaseType: ${{ parameters.databaseConfig.dbType }} + databaseName: ${{ parameters.databaseConfig.dbName }} + owner: ${{ user.entity.metadata.name or 'guest' }} + sourceRepo: ${{ steps['publish-source'].output.remoteUrl }} + gitopsRepo: ${{ steps['publish-source'].output.remoteUrl | replace(".git", "") }}-gitops + testCommand: "gradle test" + testImage: "gradle:8.7-jdk21" + + - id: publish-gitops + name: Publish GitOps Manifests + action: publish:gitea + input: + description: GitOps Manifests for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }}-gitops + sourcePath: ./gitops + repoVisibility: public + + # 3. Secret + Deploy + - id: create-secret + name: Create Git Credentials Secret + action: kubernetes:create-git-credentials-secret + input: + name: ${{ parameters.repoName }} + namespace: default + username: ${{ (parameters.repoUrl | parseRepoUrl).owner }} + webhookSecret: ${{ parameters.repoName }} + + - id: apply-helios + name: Deploy to Kubernetes + action: kubernetes:apply + input: + manifestPath: ./gitops/helios-app.yaml + namespaced: true + + # 4. Registration + - id: register + name: Register Component + action: catalog:register + if: '{{ parameters.registerToCatalog }}' + input: + repoContentsUrl: ${{ steps['publish-source'].output.repoContentsUrl }} + catalogInfoPath: 'catalog-info.yaml' + + - id: notify + name: Notify User + action: notification:send + if: '{{ parameters.sendNotification }}' + input: + recipients: entity + entityRefs: + - user:default/guest + title: 'Spring Boot Template Executed' + info: 'Your Spring Boot application has been scaffolded with a PostgreSQL database!' + + output: + links: + - title: Source Repository + url: ${{ steps['publish-source'].output.remoteUrl }} + - title: GitOps Repository + url: ${{ steps['publish-gitops'].output.remoteUrl }} + - title: Open in Catalog + icon: catalog + entityRef: ${{ steps['register'].output.entityRef }} diff --git a/apps/portal/packages/app/src/scaffolder/DatabasePickerExtension/DatabasePicker.tsx b/apps/portal/packages/app/src/scaffolder/DatabasePickerExtension/DatabasePicker.tsx index 6fa89ad..4857725 100644 --- a/apps/portal/packages/app/src/scaffolder/DatabasePickerExtension/DatabasePicker.tsx +++ b/apps/portal/packages/app/src/scaffolder/DatabasePickerExtension/DatabasePicker.tsx @@ -24,7 +24,7 @@ export const DatabasePicker = ({ // Default to 'none' if no data is present const dbType = formData?.dbType || 'none'; const dbName = formData?.dbName || ''; - const dbVersion = formData?.dbVersion || '16'; + const dbVersion = formData?.dbVersion || '18.3'; const handleTypeChange = (event: React.ChangeEvent<{ value: unknown }>) => { const newType = event.target.value as string; @@ -80,10 +80,8 @@ export const DatabasePicker = ({ Database Version diff --git a/apps/portal/packages/backend/src/actions/kubernetes-apply.ts b/apps/portal/packages/backend/src/actions/kubernetes-apply.ts index 94abe6c..4672b6c 100644 --- a/apps/portal/packages/backend/src/actions/kubernetes-apply.ts +++ b/apps/portal/packages/backend/src/actions/kubernetes-apply.ts @@ -10,11 +10,19 @@ export const createKubernetesApplyAction = () => { description: 'Applies a Kubernetes manifest file using kubectl', schema: { input: z => - z.object({ - manifestPath: z + z + .object({ + manifestPath: z + .string() + .optional() + .describe( + 'Path to the manifest file to apply, relative to the workspace', + ), + resource: z .string() + .optional() .describe( - 'Path to the manifest file to apply, relative to the workspace', + 'Alias for manifestPath (kept for backwards compatibility with older templates)', ), namespace: z .string() @@ -24,16 +32,33 @@ export const createKubernetesApplyAction = () => { .boolean() .optional() .describe('Whether the resources are namespaced'), - }), + values: z + .record(z.any()) + .optional() + .describe( + 'Optional values (ignored by this action; use fetch:template to render manifests)', + ), + }) + .refine(v => Boolean(v.manifestPath || v.resource), { + message: 'manifestPath (or resource) is required', + }), }, async handler(ctx) { - const { manifestPath, namespace } = ctx.input; + const { manifestPath, resource, namespace, values } = ctx.input; - if (!manifestPath) { - throw new InputError('manifestPath is required'); + const effectiveManifestPath = manifestPath ?? resource; + + if (!effectiveManifestPath) { + throw new InputError('manifestPath (or resource) is required'); + } + + if (values && Object.keys(values).length > 0) { + ctx.logger.warn( + 'kubernetes:apply received input.values but does not perform templating; ensure manifests are rendered by fetch:template before applying', + ); } - const args = ['apply', '-f', manifestPath]; + const args = ['apply', '-f', effectiveManifestPath]; if (namespace) { args.push('-n', namespace); } @@ -49,7 +74,10 @@ export const createKubernetesApplyAction = () => { }, }); - ctx.logger.info(`Successfully applied manifest: ${manifestPath}`); + ctx.logger.info( + `Successfully applied manifest: ${effectiveManifestPath}`, + ); }, }); }; + diff --git a/apps/portal/packages/backend/src/actions/kubernetes-create-secret.ts b/apps/portal/packages/backend/src/actions/kubernetes-create-secret.ts new file mode 100644 index 0000000..62104a9 --- /dev/null +++ b/apps/portal/packages/backend/src/actions/kubernetes-create-secret.ts @@ -0,0 +1,84 @@ +import { + createTemplateAction, + executeShellCommand, +} from '@backstage/plugin-scaffolder-node'; +import { InputError } from '@backstage/errors'; + +export const createKubernetesCreateSecretAction = () => { + return createTemplateAction({ + id: 'kubernetes:create-secret', + description: 'Creates (or replaces) a Kubernetes Secret using kubectl', + schema: { + input: z => + z.object({ + namespace: z + .string() + .optional() + .describe('Kubernetes namespace (defaults to "default")'), + secretName: z.string().describe('Name of the Secret to create'), + type: z + .string() + .optional() + .describe('Optional Secret type (defaults to Opaque)'), + data: z + .record(z.string()) + .describe( + 'Key/value pairs to populate the Secret (values are treated as stringData)', + ), + }), + }, + async handler(ctx) { + const { namespace = 'default', secretName, type, data } = ctx.input; + + if (!secretName) { + throw new InputError('secretName is required'); + } + if (!data || Object.keys(data).length === 0) { + throw new InputError('data must contain at least one key/value pair'); + } + + ctx.logger.info( + `Creating secret ${secretName} in namespace ${namespace} with ${Object.keys(data).length} keys`, + ); + + // Delete existing secret if it exists (ignore errors) + try { + await executeShellCommand({ + command: 'kubectl', + args: [ + 'delete', + 'secret', + secretName, + '-n', + namespace, + '--ignore-not-found', + ], + logger: ctx.logger, + }); + } catch { + // ignore + } + + const args = ['create', 'secret', 'generic', secretName, '-n', namespace]; + + if (type) { + args.push(`--type=${type}`); + } + + for (const [key, value] of Object.entries(data)) { + if (!key) { + throw new InputError('Secret data keys must be non-empty'); + } + args.push(`--from-literal=${key}=${value}`); + } + + await executeShellCommand({ + command: 'kubectl', + args, + logger: ctx.logger, + }); + + ctx.logger.info(`Successfully created secret: ${secretName}`); + }, + }); +}; diff --git a/apps/portal/packages/backend/src/extensions/scaffolder.ts b/apps/portal/packages/backend/src/extensions/scaffolder.ts index 84deaa3..8113ca8 100644 --- a/apps/portal/packages/backend/src/extensions/scaffolder.ts +++ b/apps/portal/packages/backend/src/extensions/scaffolder.ts @@ -4,6 +4,7 @@ import { coreServices } from '@backstage/backend-plugin-api'; import { createKubernetesApplyAction } from '../actions/kubernetes-apply'; import { createGitCredentialsSecretAction } from '../actions/create-git-credentials-secret'; import { createGiteaWebhookAction } from '../actions/create-gitea-webhook'; +import { createKubernetesCreateSecretAction } from '../actions/kubernetes-create-secret'; export const scaffolderModuleCustomActions = createBackendModule({ pluginId: 'scaffolder', @@ -16,6 +17,7 @@ export const scaffolderModuleCustomActions = createBackendModule({ }, async init({ scaffolder, config }) { scaffolder.addActions(createKubernetesApplyAction() as any); + scaffolder.addActions(createKubernetesCreateSecretAction() as any); scaffolder.addActions(createGitCredentialsSecretAction() as any); scaffolder.addActions(createGiteaWebhookAction({ config }) as any); }, diff --git a/apps/portal/plugins/helios-backend/src/service/router.ts b/apps/portal/plugins/helios-backend/src/service/router.ts index 71da1ef..f8e4120 100644 --- a/apps/portal/plugins/helios-backend/src/service/router.ts +++ b/apps/portal/plugins/helios-backend/src/service/router.ts @@ -9,10 +9,33 @@ export interface RouterOptions { config: Config; } +function isNotFoundError(err: unknown): boolean { + const statusCode = + (err as { statusCode?: number; response?: { statusCode?: number } }) + ?.statusCode ?? + (err as { response?: { statusCode?: number } })?.response?.statusCode ?? + (err as { body?: { code?: number } })?.body?.code; + + if (statusCode === 404) { + return true; + } + + const msg = String(err).toLowerCase(); + return ( + msg.includes('not found') || + (msg.includes('status code') && msg.includes('404')) + ); +} + /** Matches Helm operator: GetDatabaseSecretName / GetDatabaseHost (traits.go). */ -const databaseSecretName = (componentName: string) => - `${componentName}-db-secret`; -const databasePodLabel = (componentName: string) => `app=${componentName}-db`; +const databaseSecretNames = (componentName: string) => [ + `${componentName}-db-secret`, + `${componentName}-backend-db-secret`, +]; +const databasePodLabels = (componentName: string) => [ + `app=${componentName}-db`, + `app=${componentName}-backend-db`, +]; function parseTraitProperties(raw: unknown): Record | null { if (raw && typeof raw === 'object' && !Array.isArray(raw)) { @@ -71,7 +94,7 @@ export async function createRouter(options: RouterOptions): Promise { router.get('/info/:componentName', async (req, res) => { const { componentName } = req.params; const namespace = 'default'; - const secretName = databaseSecretName(componentName); + const secretCandidates = databaseSecretNames(componentName); let dbName = `${componentName}-db`; try { @@ -89,27 +112,34 @@ export async function createRouter(options: RouterOptions): Promise { } let secret: k8s.V1Secret | undefined; - try { - const read = await k8sApi.readNamespacedSecret({ - name: secretName, - namespace, - }); - secret = (read as { body?: k8s.V1Secret }).body ?? (read as k8s.V1Secret); - } catch (err: unknown) { - const code = - (err as { statusCode?: number; response?: { statusCode?: number } }) - ?.statusCode ?? - (err as { response?: { statusCode?: number } })?.response?.statusCode; - if (code === 404) { - return res.status(404).json({ - error: `Secret ${secretName} not found in namespace ${namespace}`, + let resolvedSecretName = ''; + for (const secretName of secretCandidates) { + try { + const read = await k8sApi.readNamespacedSecret({ + name: secretName, + namespace, }); + secret = + (read as { body?: k8s.V1Secret }).body ?? (read as k8s.V1Secret); + resolvedSecretName = secretName; + break; + } catch (err: unknown) { + if (!isNotFoundError(err)) { + logger.error( + `Failed to read database secret ${secretName}: ${String(err)}`, + ); + return res.status(500).json({ + error: err instanceof Error ? err.message : String(err), + }); + } } - logger.error( - `Failed to read database secret ${secretName}: ${String(err)}`, - ); - return res.status(500).json({ - error: err instanceof Error ? err.message : String(err), + } + + if (!secret) { + return res.status(404).json({ + error: `No database secret found in namespace ${namespace}. Tried: ${secretCandidates.join( + ', ', + )}`, }); } @@ -119,16 +149,21 @@ export async function createRouter(options: RouterOptions): Promise { let status: 'Running' | 'Failed' | 'Unknown' = 'Unknown'; try { - const pods: unknown = await k8sApi.listNamespacedPod({ - namespace, - labelSelector: databasePodLabel(componentName), - }); - const podList = (pods as { body?: k8s.V1PodList }).body ?? pods; - const items = (podList as k8s.V1PodList)?.items; - if (items && items.length > 0) { + for (const labelSelector of databasePodLabels(componentName)) { + const pods: unknown = await k8sApi.listNamespacedPod({ + namespace, + labelSelector, + }); + const podList = (pods as { body?: k8s.V1PodList }).body ?? pods; + const items = (podList as k8s.V1PodList)?.items; + if (!items || items.length === 0) { + continue; + } + const phase = items[0].status?.phase; if (phase === 'Running') status = 'Running'; else if (phase === 'Failed') status = 'Failed'; + break; } } catch { // Pod listing is best-effort. @@ -138,6 +173,7 @@ export async function createRouter(options: RouterOptions): Promise { const port = portStr ? parseInt(portStr, 10) : 5432; return res.json({ + secretName: resolvedSecretName, host: decode(data.DB_HOST), port, user: decode(data.DB_USER), diff --git a/cue/definitions/bases/deployment.cue b/cue/definitions/bases/deployment.cue index 378ec6f..64b39e9 100644 --- a/cue/definitions/bases/deployment.cue +++ b/cue/definitions/bases/deployment.cue @@ -9,7 +9,13 @@ package bases image: string replicas: int | *1 port: int - env: [...{name: string, value: string}] | *[] + + // FIX: Allow both standard 'value' strings and Kubernetes 'valueFrom' objects + env: [...{ + name: string + value?: string + valueFrom?: {...} + }] | *[] } output: { @@ -42,4 +48,4 @@ package bases } } } -} +} \ No newline at end of file diff --git a/cue/definitions/components/web-service.cue b/cue/definitions/components/web-service.cue index e9237dc..bcaaaa2 100644 --- a/cue/definitions/components/web-service.cue +++ b/cue/definitions/components/web-service.cue @@ -12,7 +12,14 @@ import "helios.io/cue/definitions/bases" image: string replicas: int | *1 port: int | *8080 - env: [...{name: string, value: string}] | *[] + + // FIX: Make 'value' optional and allow 'valueFrom' objects + // This permits standard Kubernetes Secret references! + env: [...{ + name: string + value?: string + valueFrom?: {...} + }] | *[] } // Alias để tránh scope issues @@ -29,7 +36,5 @@ import "helios.io/cue/definitions/bases" env: _p.env } }).output - - } -} +} \ No newline at end of file diff --git a/cue/definitions/tekton/base_trigger.cue b/cue/definitions/tekton/base_trigger.cue index 115536a..364947f 100644 --- a/cue/definitions/tekton/base_trigger.cue +++ b/cue/definitions/tekton/base_trigger.cue @@ -156,9 +156,10 @@ package tekton replicas: int | *1 port: int | *8080 testCommand: string | *"" - testImage: string | *"node:20" + testImage: string | *"node:24" serviceAccount: string | *"default" dockerSecret: string | *"docker-credentials" + databaseSecretRef: string | *"api-db-secret" // Argo CD sync via kubectl patch on Application argoCDNamespace: string | *"argocd" diff --git a/cue/definitions/tekton/common.cue b/cue/definitions/tekton/common.cue index 8ca4b36..5b2cfb7 100644 --- a/cue/definitions/tekton/common.cue +++ b/cue/definitions/tekton/common.cue @@ -9,11 +9,11 @@ package tekton #Defaults: { // Container images - PIN VERSION for reproducibility images: { - gitClone: "alpine/git:v2.43.0" - kaniko: "gcr.io/kaniko-project/executor:v1.19.2" - alpine: "alpine:3.19" - yq: "mikefarah/yq:4.40.5" - kubectl: "bitnami/kubectl:1.31.4" + gitClone: "alpine/git:v2.52.0" + kaniko: "gcr.io/kaniko-project/executor:v1.24.0" + alpine: "alpine:3.23" + yq: "mikefarah/yq:4.52.5" + kubectl: "bitnami/kubectl:latest" } // Secret names @@ -163,7 +163,7 @@ package tekton name: "test-image" description: "Image to use for running tests" type: "string" - default: "node:20" + default: "node:24" } } diff --git a/cue/definitions/tekton/pipelines/db-migrate.cue b/cue/definitions/tekton/pipelines/db-migrate.cue new file mode 100644 index 0000000..8e34139 --- /dev/null +++ b/cue/definitions/tekton/pipelines/db-migrate.cue @@ -0,0 +1,105 @@ +// db-migrate pipeline definition. +// Specialized pipeline for database migrations and PostgREST schema reload. +// Use case: Run migrations on database schema changes without rebuilding images. +package pipelines + +// ===================================================== +// PIPELINE DEFINITION +// Database Migration Pipeline +// ===================================================== + +// Database-specific parameters +_dbMigrateParams: [ + { + name: "app-repo-url" + description: "URL of the application source repository" + type: "string" + }, + { + name: "app-repo-revision" + description: "Git revision/branch (default: main)" + type: "string" + default: "main" + }, + { + name: "db-secret-name" + description: "Kubernetes Secret name containing database credentials (expects key PGRST_DB_URI)" + type: "string" + default: "api-db-secret" + }, + { + name: "migration-source" + description: "Path to migrations directory in repo (default: db/migrations)" + type: "string" + default: "db/migrations" + }, + { + name: "namespace" + description: "Kubernetes namespace where the app is running" + type: "string" + default: "default" + }, +] + +_dbMigrateWorkspaces: [ + { + name: "source" + description: "Workspace for cloning the source repository" + }, +] + +_dbMigrateConfig: { + description: "Database migration pipeline: clone repo → run migrations → reload PostgREST schema" + + params: _dbMigrateParams + + workspaces: _dbMigrateWorkspaces + + tasks: [ + // 1. Clone the source repository + { + name: "clone-repo" + taskRef: {name: "git-clone"} + workspaces: [{ + name: "output" + workspace: "source" + }] + params: [ + {name: "url", value: "$(params.app-repo-url)"}, + {name: "revision", value: "$(params.app-repo-revision)"}, + ] + }, + + // 2. Run database migrations (after clone) + { + name: "run-migrations" + taskRef: {name: "db-migrate"} + runAfter: ["clone-repo"] + workspaces: [{ + name: "source" + workspace: "source" + }] + params: [ + {name: "db-secret-name", value: "$(params.db-secret-name)"}, + {name: "migration-source", value: "$(params.migration-source)"}, + ] + }, + + // 3. Reload PostgREST schema cache (after migrations) + { + name: "reload-postgrest" + taskRef: {name: "postgrest-reload"} + runAfter: ["run-migrations"] + params: [ + {name: "db-secret-name", value: "$(params.db-secret-name)"}, + ] + }, + ] +} + +// Register pipeline in the registry +#PipelineRegistry: "db-migrate": { + name: "db-migrate" + description: "Database migration pipeline for PostgREST applications" + config: _dbMigrateConfig +} diff --git a/cue/definitions/tekton/schema.cue b/cue/definitions/tekton/schema.cue index e01c1d1..e15a33c 100644 --- a/cue/definitions/tekton/schema.cue +++ b/cue/definitions/tekton/schema.cue @@ -41,9 +41,11 @@ package tekton // === TESTING === testCommand?: string // e.g. "npm test" + testImage?: string // e.g. "node:24" // === SECRETS === - dockerSecret: string | *"docker-credentials" + dockerSecret: string | *"docker-credentials" + databaseSecretRef: string | *"api-db-secret" // === ARGOCD === argoCDNamespace: string | *"argocd" diff --git a/cue/definitions/tekton/tasks/db-migrate.cue b/cue/definitions/tekton/tasks/db-migrate.cue new file mode 100644 index 0000000..b5fbb25 --- /dev/null +++ b/cue/definitions/tekton/tasks/db-migrate.cue @@ -0,0 +1,68 @@ +package tasks + +import "helios.io/cue/definitions/tekton" + +// Database Migration Task using golang-migrate +// Runs database migrations from the source repository +#DBMigrate: tekton.#TektonTask & { + parameter: { + name: "db-migrate" + } + + _config: tekton.#Defaults + + output: spec: { + params: [ + { + name: "migration-source" + description: "Path to migrations directory in the cloned repo (e.g., db/migration or db/migrations)" + type: "string" + default: "db/migrations" + }, + { + name: "db-secret-name" + description: "Kubernetes Secret name containing database credentials (expects key PGRST_DB_URI)" + type: "string" + default: "api-db-secret" + }, + ] + + workspaces: [{ + name: "source" + description: "Workspace containing cloned repository with migrations" + }] + + steps: [{ + name: "migrate" + image: "migrate/migrate:v4.17.0" + workingDir: "$(workspaces.source.path)" + env: [{ + name: "DATABASE_URL" + valueFrom: { + secretKeyRef: { + name: "$(params.db-secret-name)" + key: "PGRST_DB_URI" + } + } + }] + script: """ + #!/bin/sh + set -e + + MIGRATIONS_DIR="$(workspaces.source.path)/$(params.migration-source)" + + if [ ! -d "$MIGRATIONS_DIR" ]; then + echo "ERROR: Migrations directory not found at $MIGRATIONS_DIR" + echo "Expected directory path: $MIGRATIONS_DIR" + exit 1 + fi + + echo "Running database migrations from $MIGRATIONS_DIR" + + migrate -path "$MIGRATIONS_DIR" -database "$DATABASE_URL" up + + echo "SUCCESS: Migrations completed successfully" + """ + }] + } +} diff --git a/cue/definitions/tekton/tasks/git-clone.cue b/cue/definitions/tekton/tasks/git-clone.cue index 3e25e78..5fc9195 100644 --- a/cue/definitions/tekton/tasks/git-clone.cue +++ b/cue/definitions/tekton/tasks/git-clone.cue @@ -32,9 +32,22 @@ import "helios.io/cue/definitions/tekton" rm -rf $(workspaces.output.path)/* rm -rf $(workspaces.output.path)/.[!.]* + # Transform localhost URLs for in-cluster access + GIT_URL="$(params.url)" + case "$GIT_URL" in + *localhost:3030*) + echo "Transforming localhost URL to in-cluster address" + GIT_URL=$(echo "$GIT_URL" | sed 's|http://localhost:3030/|http://gitea-http.gitea.svc.cluster.local:3000/|g') + echo "Transformed URL: $GIT_URL" + ;; + *) + echo "Using URL as-is: $GIT_URL" + ;; + esac + # Clone the repository - echo "Cloning $(params.url) to $(workspaces.output.path)" - git clone $(params.url) $(workspaces.output.path) + echo "Cloning $GIT_URL to $(workspaces.output.path)" + git clone "$GIT_URL" $(workspaces.output.path) # Checkout the specified revision cd $(workspaces.output.path) diff --git a/cue/definitions/tekton/tasks/git-update.cue b/cue/definitions/tekton/tasks/git-update.cue index 91168c5..63ddfde 100644 --- a/cue/definitions/tekton/tasks/git-update.cue +++ b/cue/definitions/tekton/tasks/git-update.cue @@ -60,7 +60,6 @@ import "helios.io/cue/definitions/tekton" securityContext: { runAsUser: 0 } - entrypoint: ["/bin/sh"] // Override default yq entrypoint script: """ #!/bin/sh set -e @@ -69,23 +68,38 @@ import "helios.io/cue/definitions/tekton" export IMAGE_URL="$(params.NEW_IMAGE_URL)" export REPLICAS="$(params.REPLICAS)" export PORT="$(params.PORT)" + + # Defensive defaults: avoid generating invalid manifests when inputs are empty/0 + if [ -z "${REPLICAS}" ] || [ "${REPLICAS}" = "0" ]; then + export REPLICAS="1" + fi + if [ -z "${PORT}" ] || [ "${PORT}" = "0" ]; then + export PORT="8080" + fi MANIFEST_PATH="$(params.MANIFEST_PATH)" # Logic tạo file tự động if echo "$MANIFEST_PATH" | grep -qvE '\\.ya?ml$'; then echo "Path '$MANIFEST_PATH' treated as DIRECTORY." mkdir -p "$MANIFEST_PATH" - - DEP_FILE="$MANIFEST_PATH/deployment.yaml" - SVC_FILE="$MANIFEST_PATH/service.yaml" - MANIFEST_FILES="$DEP_FILE $SVC_FILE" - APP_NAME=$(basename "$MANIFEST_PATH") - - if [ ! -f "$DEP_FILE" ]; then - echo "Creating default manifests..." - printf "apiVersion: apps/v1\\nkind: Deployment\\nmetadata:\\n name: ${APP_NAME}\\nspec:\\n replicas: ${REPLICAS}\\n selector:\\n matchLabels:\\n app: ${APP_NAME}\\n template:\\n metadata:\\n labels:\\n app: ${APP_NAME}\\n spec:\\n containers:\\n - name: app\\n image: ${IMAGE_URL}\\n ports:\\n - containerPort: ${PORT}\\n" > "$DEP_FILE" - - printf "apiVersion: v1\\nkind: Service\\nmetadata:\\n name: ${APP_NAME}\\nspec:\\n selector:\\n app: ${APP_NAME}\\n ports:\\n - protocol: TCP\\n port: ${PORT}\\n targetPort: ${PORT}\\n type: ClusterIP\\n" > "$SVC_FILE" + + # If the operator already renders a combined manifest.yaml in this directory, + # prefer updating that file rather than creating separate default manifests. + COMBINED_FILE="$MANIFEST_PATH/manifest.yaml" + if [ -f "$COMBINED_FILE" ]; then + MANIFEST_FILES="$COMBINED_FILE" + else + DEP_FILE="$MANIFEST_PATH/deployment.yaml" + SVC_FILE="$MANIFEST_PATH/service.yaml" + MANIFEST_FILES="$DEP_FILE $SVC_FILE" + APP_NAME=$(basename "$MANIFEST_PATH") + + if [ ! -f "$DEP_FILE" ]; then + echo "Creating default manifests..." + printf "apiVersion: apps/v1\\nkind: Deployment\\nmetadata:\\n name: ${APP_NAME}\\nspec:\\n replicas: ${REPLICAS}\\n selector:\\n matchLabels:\\n app: ${APP_NAME}\\n template:\\n metadata:\\n labels:\\n app: ${APP_NAME}\\n spec:\\n containers:\\n - name: app\\n image: ${IMAGE_URL}\\n ports:\\n - containerPort: ${PORT}\\n" > "$DEP_FILE" + + printf "apiVersion: v1\\nkind: Service\\nmetadata:\\n name: ${APP_NAME}\\nspec:\\n selector:\\n app: ${APP_NAME}\\n ports:\\n - protocol: TCP\\n port: ${PORT}\\n targetPort: ${PORT}\\n type: ClusterIP\\n" > "$SVC_FILE" + fi fi else echo "Path '$MANIFEST_PATH' treated as FILE." @@ -106,6 +120,7 @@ import "helios.io/cue/definitions/tekton" yq -i 'select(.kind == "Deployment") .spec.template.spec.containers[].image = env(IMAGE_URL)' "$FILE" yq -i 'select(.kind == "Deployment") .spec.replicas = env(REPLICAS)' "$FILE" yq -i 'select(.kind == "Deployment") .spec.template.spec.containers[].ports[0].containerPort = env(PORT)' "$FILE" + yq -i 'select(.kind == "Service") .spec.ports[0].port = env(PORT)' "$FILE" yq -i 'select(.kind == "Service") .spec.ports[0].targetPort = env(PORT)' "$FILE" fi done diff --git a/cue/definitions/tekton/tasks/postgrest-reload.cue b/cue/definitions/tekton/tasks/postgrest-reload.cue new file mode 100644 index 0000000..1b55d1b --- /dev/null +++ b/cue/definitions/tekton/tasks/postgrest-reload.cue @@ -0,0 +1,47 @@ +package tasks + +import "helios.io/cue/definitions/tekton" + +// PostgREST Schema Reload Task +// Triggers PostgREST to reload schema cache via NOTIFY command +// This ensures the API immediately reflects database changes +#PostgRESTReload: tekton.#TektonTask & { + parameter: { + name: "postgrest-reload" + } + + _config: tekton.#Defaults + + output: spec: { + params: [{ + name: "db-secret-name" + description: "Kubernetes Secret name containing database credentials (expects key PGRST_DB_URI)" + type: "string" + default: "api-db-secret" + }] + + steps: [{ + name: "reload-schema" + image: "postgres:15-alpine" + env: [{ + name: "DATABASE_URL" + valueFrom: { + secretKeyRef: { + name: "$(params.db-secret-name)" + key: "PGRST_DB_URI" + } + } + }] + script: """ + #!/bin/sh + set -e + + echo "Triggering PostgREST schema reload..." + + psql "$DATABASE_URL" -c "NOTIFY pgrst, 'reload schema';" + + echo "SUCCESS: Schema reload triggered successfully" + """ + }] + } +} diff --git a/cue/definitions/tekton/tasks/registry.cue b/cue/definitions/tekton/tasks/registry.cue index 2e209a2..f5d7a91 100644 --- a/cue/definitions/tekton/tasks/registry.cue +++ b/cue/definitions/tekton/tasks/registry.cue @@ -7,6 +7,8 @@ package tasks "kaniko-build": #KanikoBuild "git-update-manifest": #GitUpdateManifest "argocd-sync": #ArgoCDSync + "db-migrate": #DBMigrate + "postgrest-reload": #PostgRESTReload } // Helper to render a specific task with optional context injection diff --git a/cue/definitions/tekton/triggers/db-migrate-trigger.cue b/cue/definitions/tekton/triggers/db-migrate-trigger.cue new file mode 100644 index 0000000..c5938d5 --- /dev/null +++ b/cue/definitions/tekton/triggers/db-migrate-trigger.cue @@ -0,0 +1,152 @@ +package triggers + +import ( + "helios.io/cue/definitions/tekton" +) + +// ===================================================== +// DATABASE MIGRATION TRIGGER BUNDLE +// Triggers db-migrate pipeline only on changes to db/migration path +// ===================================================== + +#DatabaseMigrationTriggerBundle: tekton.#TriggerBundle & { + // Alias the parameter field to bundleParams for global access + bundleParams=parameter: _ + + // 1. TRIGGER BINDING + // Extracts git information from webhook payload + _binding: tekton.#TektonTriggerBinding & { + parameter: { + name: "\(bundleParams.appName)-db-migrate-binding" + namespace: bundleParams.namespace + } + config: params: [ + {name: "git-repo-url", value: "$(body.repository.clone_url)"}, + {name: "git-revision", value: "$(body.after)"}, + ] + } + + // 2. TRIGGER TEMPLATE + // Creates a PipelineRun for db-migrate pipeline + _template: tekton.#TektonTriggerTemplate & { + let _bp = bundleParams + + parameter: { + name: "\(_bp.appName)-db-migrate-template" + namespace: _bp.namespace + } + config: { + params: [ + {name: "git-repo-url", description: "Repository URL from webhook"}, + {name: "git-revision", description: "Git commit SHA from webhook"}, + ] + + // PipelineRun for db-migrate pipeline + resourcetemplates: [{ + apiVersion: "tekton.dev/v1beta1" + kind: "PipelineRun" + metadata: { + // Truncate appName to at most 32 chars to keep total name ≤63 chars. + // CUE slice [:n] panics if string is shorter than n, so we guard with a conditional. + let _namePrefix = [if len(_bp.appName) > 32 {_bp.appName[:32]}, _bp.appName][0] + name: "\(_namePrefix)-migrate-$(uid)" + namespace: _bp.namespace + labels: { + "helios.io/managed-by": "helios-operator" + "app.kubernetes.io/part-of": "helios-platform" + "app.kubernetes.io/instance": "db-migrate" + "app.kubernetes.io/name": _bp.appName + "janus-idp.io/tekton": _bp.appName + "tekton.dev/pipeline": "db-migrate" + } + } + spec: { + pipelineRef: { + name: "db-migrate" + } + serviceAccountName: _bp.serviceAccount + + params: [ + {name: "app-repo-url", value: "$(tt.params.git-repo-url)"}, + {name: "app-repo-revision", value: "$(tt.params.git-revision)"}, + {name: "db-secret-name", value: _bp.databaseSecretRef}, + {name: "migration-source", value: "db/migrations"}, + {name: "namespace", value: _bp.namespace}, + ] + + workspaces: [{ + name: "source" + volumeClaimTemplate: { + spec: { + accessModes: ["ReadWriteOnce"] + resources: requests: storage: "1Gi" + } + } + }] + } + }] + } + } + + // 3. EVENT LISTENER + // Listens for push events and filters by db/migration path using CEL + _listener: tekton.#TektonEventListener & { + parameter: { + name: "\(bundleParams.appName)-db-migrate-listener" + namespace: bundleParams.namespace + } + config: { + triggers: [{ + name: "db-migrate-push" + bindings: [{ref: _binding.parameter.name}] + template: {ref: _template.parameter.name} + + // CEL interceptor to filter only db/migration** changes + // This ensures migration pipeline only runs when migrations directory is modified + interceptors: [ + { + // GitHub/Gitea webhook interceptor for authentication + ref: {name: "github", kind: "ClusterInterceptor"} + params: [ + {name: "secretRef", value: { + secretName: bundleParams.webhookSecret + secretKey: "secret" + }}, + {name: "eventTypes", value: ["push"]}, + ] + }, + { + // CEL interceptor to transform URLs for in-cluster access + // Replaces localhost:3030 with in-cluster Gitea address + ref: {name: "cel", kind: "ClusterInterceptor"} + params: [{ + name: "overlays" + value: [ + { + key: "repository.clone_url" + expression: "body.repository.clone_url.replace('http://localhost:3030/', 'http://gitea-http.gitea.svc.cluster.local:3000/')" + }, + ] + }] + }, + { + // CEL filter to only trigger if migrations path changed + // Includes both added + modified files, matches db/migration or db/migrations + ref: {name: "cel", kind: "ClusterInterceptor"} + params: [{ + name: "filter" + value: "has(body.commits) && body.commits.exists(c, (has(c.added) && c.added.exists(f, f.startsWith('db/migration/') || f.startsWith('db/migrations/'))) || (has(c.modified) && c.modified.exists(f, f.startsWith('db/migration/') || f.startsWith('db/migrations/'))))" + }] + }, + ] + }] + } + } + + // 4. BUNDLE OUTPUTS + outputs: [ + _binding.output, + _template.output, + _listener.output, + ] +} diff --git a/cue/definitions/tekton/triggers/github-push.cue b/cue/definitions/tekton/triggers/github-push.cue index 3e06cdb..4733a0c 100644 --- a/cue/definitions/tekton/triggers/github-push.cue +++ b/cue/definitions/tekton/triggers/github-push.cue @@ -24,7 +24,7 @@ import ( ] } - // 2. TRIGGER TEMPLATE + // 2. TRIGGER TEMPLATES _template: tekton.#TektonTriggerTemplate & { // Capture bundleParams locally let _bp = bundleParams @@ -44,7 +44,9 @@ import ( apiVersion: "tekton.dev/v1beta1" kind: "PipelineRun" metadata: { - name: "\(_bp.appName)-run-$(uid)" + // Truncate to at most 46 chars: "{prefix}-run-$(uid)" stays ≤63. + let _namePrefix = [if len(_bp.appName) > 46 {_bp.appName[:46]}, _bp.appName][0] + name: "\(_namePrefix)-run-$(uid)" namespace: _bp.namespace labels: { "helios.io/managed-by": "helios-operator" @@ -104,6 +106,61 @@ import ( } } + _dbMigrateTemplate: tekton.#TektonTriggerTemplate & { + let _bp = bundleParams + + parameter: { + name: "\(_bp.appName)-db-migrate-template" + namespace: _bp.namespace + } + config: { + params: [ + {name: "git-revision", description: "From Webhook"}, + ] + + resourcetemplates: [{ + apiVersion: "tekton.dev/v1beta1" + kind: "PipelineRun" + metadata: { + // Truncate to at most 32 chars: "{prefix}-migrate-$(uid)" stays ≤63. + let _namePrefix = [if len(_bp.appName) > 32 {_bp.appName[:32]}, _bp.appName][0] + name: "\(_namePrefix)-migrate-$(uid)" + namespace: _bp.namespace + labels: { + "helios.io/managed-by": "helios-operator" + "app.kubernetes.io/part-of": "helios-platform" + "app.kubernetes.io/instance": "db-migrate" + "app.kubernetes.io/name": _bp.appName + "janus-idp.io/tekton": _bp.appName + "tekton.dev/pipeline": "db-migrate" + } + } + spec: { + pipelineRef: {name: "db-migrate"} + serviceAccountName: _bp.serviceAccount + + params: [ + {name: "app-repo-url", value: _bp.gitRepo}, + {name: "app-repo-revision", value: "$(tt.params.git-revision)"}, + {name: "db-secret-name", value: _bp.databaseSecretRef}, + {name: "migration-source", value: "db/migrations"}, + {name: "namespace", value: _bp.namespace}, + ] + + workspaces: [{ + name: "source" + volumeClaimTemplate: { + spec: { + accessModes: ["ReadWriteOnce"] + resources: requests: storage: "1Gi" + } + } + }] + } + }] + } + } + // 3. EVENT LISTENER _listener: tekton.#TektonEventListener & { parameter: { @@ -111,23 +168,50 @@ import ( namespace: bundleParams.namespace } config: { - triggers: [{ - name: "gitea-push" - bindings: [{ref: _binding.parameter.name}] - template: {ref: _template.parameter.name} - - // Use the cluster Git webhook interceptor for push event validation. - interceptors: [{ - ref: {name: "github", kind: "ClusterInterceptor"} - params: [ - {name: "secretRef", value: { - secretName: bundleParams.webhookSecret - secretKey: "secret" - }}, - {name: "eventTypes", value: ["push"]}, + triggers: [ + { + name: "gitea-push" + bindings: [{ref: _binding.parameter.name}] + template: {ref: _template.parameter.name} + + // Use the cluster Git webhook interceptor for push event validation. + interceptors: [{ + ref: {name: "github", kind: "ClusterInterceptor"} + params: [ + {name: "secretRef", value: { + secretName: bundleParams.webhookSecret + secretKey: "secret" + }}, + {name: "eventTypes", value: ["push"]}, + ] + }] + }, + { + name: "db-migrate-on-migrations" + bindings: [{ref: _binding.parameter.name}] + template: {ref: _dbMigrateTemplate.parameter.name} + + interceptors: [ + { + ref: {name: "github", kind: "ClusterInterceptor"} + params: [ + {name: "secretRef", value: { + secretName: bundleParams.webhookSecret + secretKey: "secret" + }}, + {name: "eventTypes", value: ["push"]}, + ] + }, + { + ref: {name: "cel", kind: "ClusterInterceptor"} + params: [{ + name: "filter" + value: "has(body.commits) && body.commits.exists(c, (has(c.added) && c.added.exists(f, f.startsWith('db/migration/') || f.startsWith('db/migrations/'))) || (has(c.modified) && c.modified.exists(f, f.startsWith('db/migration/') || f.startsWith('db/migrations/'))))" + }] + }, ] - }] - }] + }, + ] } } @@ -135,6 +219,7 @@ import ( outputs: [ _binding.output, _template.output, + _dbMigrateTemplate.output, _listener.output, ] } \ No newline at end of file diff --git a/cue/definitions/tekton/triggers/registry.cue b/cue/definitions/tekton/triggers/registry.cue index 714e23a..ccf37a3 100644 --- a/cue/definitions/tekton/triggers/registry.cue +++ b/cue/definitions/tekton/triggers/registry.cue @@ -11,7 +11,8 @@ import ( #TriggerRegistry: { // FIX: Remove 'tekton.' prefix. This is a local definition in the same package. - "gitea-push": #GiteaPushTriggerBundle + "gitea-push": #GiteaPushTriggerBundle + "db-migrate": #DatabaseMigrationTriggerBundle } // ===================================================== diff --git a/cue/engine/tekton_builder.cue b/cue/engine/tekton_builder.cue index 8b8f981..287158b 100644 --- a/cue/engine/tekton_builder.cue +++ b/cue/engine/tekton_builder.cue @@ -33,6 +33,12 @@ let _testCommand = [ "", ][0] +// Resolve Test Image safely. +let _testImage = [ + if tektonInput.testImage != _|_ { tektonInput.testImage }, + tekton.#CommonParams.test.image.default, +][0] + // Argo CD API URL: explicit override or in-cluster default. let _argoCDNamespace = [ if tektonInput.argoCDNamespace != _|_ { tektonInput.argoCDNamespace }, @@ -53,14 +59,27 @@ _tasks: [ } ] -// 2. RENDER PIPELINE -_pipeline: [ +// 2. RENDER PIPELINES +// Render primary pipeline always +_primaryPipeline: [ (pipelines.#RenderPipeline & { pipelineType: tektonInput.pipelineType namespace: tektonInput.namespace }).output ] +// Also render db-migrate pipeline if it's available and needed by triggers +_dbMigratePipeline: [ + if tektonInput.triggerType == "gitea-push" || tektonInput.triggerType == "db-migrate" { + (pipelines.#RenderPipeline & { + pipelineType: "db-migrate" + namespace: tektonInput.namespace + }).output + } +] + +_pipeline: list.Concat([_primaryPipeline, _dbMigratePipeline]) + // 3. RENDER TRIGGERS _triggers: (triggers.#RenderTriggers & { triggerType: tektonInput.triggerType @@ -92,10 +111,11 @@ _triggers: (triggers.#RenderTriggers & { // Use the pre-calculated concrete string testCommand: _testCommand - // Updated per Code Review: Use default from CommonParams - testImage: tekton.#CommonParams.test.image.default + // Use explicit test image when provided, else common default. + testImage: _testImage serviceAccount: tektonInput.serviceAccount dockerSecret: tektonInput.dockerSecret + databaseSecretRef: tektonInput.databaseSecretRef argoCDNamespace: _argoCDNamespace argoCDAppName: "\(tektonInput.appName)-argocd" diff --git a/docs/TESTING_DB_MIGRATE_TRIGGER.md b/docs/TESTING_DB_MIGRATE_TRIGGER.md new file mode 100644 index 0000000..86d3cc8 --- /dev/null +++ b/docs/TESTING_DB_MIGRATE_TRIGGER.md @@ -0,0 +1,637 @@ +# Testing db-migrate Trigger Implementation + +## Quick Test Summary + +| Level | Test Type | Command | Time | +|-------|-----------|---------|------| +| **Unit** | Mapper + CUE rendering | `go test ./...` | ~30s | +| **Integration** | Local K8s cluster | `kubectl apply -f` | ~5m | +| **E2E** | Actual webhook + git push | Manual push | ~2m | + +--- + +## 1️⃣ Unit Tests (Go) + +### Test 1: Verify TriggerType is read from HeliosApp + +Create or update test file: `apps/operator/internal/controller/tekton/mapper_test.go` + +```go +package tekton + +import ( + "testing" + + appv1alpha1 "github.com/helios-platform-team/helios-platform/apps/operator/api/v1alpha1" +) + +func TestMapCRDToTektonInput_TriggerType(t *testing.T) { + tests := []struct { + name string + triggerType string + expectedTrigger string + }{ + { + name: "Default trigger type is gitea-push", + triggerType: "", + expectedTrigger: "gitea-push", + }, + { + name: "db-migrate trigger type is preserved", + triggerType: "db-migrate", + expectedTrigger: "db-migrate", + }, + { + name: "Custom trigger type is passed through", + triggerType: "github-push", + expectedTrigger: "github-push", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := &appv1alpha1.HeliosApp{ + Spec: appv1alpha1.HeliosAppSpec{ + GitRepo: "https://gitea.local/user/repo", + ImageRepo: "myregistry/myapp", + GitOpsRepo: "https://gitea.local/user/gitops", + GitOpsPath: "./", + TriggerType: tt.triggerType, + }, + } + + input := MapCRDToTektonInput(app) + + if input.TriggerType != tt.expectedTrigger { + t.Errorf("Expected TriggerType=%s, got %s", tt.expectedTrigger, input.TriggerType) + } + }) + } +} + +func TestMapCRDToTektonInput_PostgRESTDefaults(t *testing.T) { + // Verify that PostgREST template sets triggerType to db-migrate + app := &appv1alpha1.HeliosApp{ + Spec: appv1alpha1.HeliosAppSpec{ + GitRepo: "https://gitea.local/user/my-api", + ImageRepo: "myregistry/my-api", + GitOpsRepo: "https://gitea.local/user/my-api-gitops", + GitOpsPath: "./", + TriggerType: "db-migrate", + }, + } + + input := MapCRDToTektonInput(app) + + if input.TriggerType != "db-migrate" { + t.Errorf("PostgREST should have db-migrate trigger, got %s", input.TriggerType) + } +} +``` + +Run test: +```bash +cd apps/operator +go test ./internal/controller/tekton/... -v -run TestMapCRDToTektonInput_TriggerType +``` + +### Test 2: Update existing CUE test to include db-migrate + +File: `apps/operator/internal/cue/tekton_test.go` + +Update the `validTektonInput()` function OR create tests for db-migrate: + +```go +// Add this test function +func validTektonInputDbMigrate() TektonInput { + input := validTektonInput() + input.TriggerType = "db-migrate" + return input +} + +func TestRenderTektonResources_DbMigrateTrigger(t *testing.T) { + cuePath := getCuePath(t) + renderer, err := NewTektonRenderer(cuePath) + if err != nil { + t.Fatalf("Failed to create TektonRenderer: %v", err) + } + + input := validTektonInputDbMigrate() + objects, err := renderer.RenderTektonResources(input) + if err != nil { + t.Fatalf("RenderTektonResources failed: %v", err) + } + + // Verify db-migrate specific resources exist + var hasDbMigrateBinding bool + var hasDbMigrateTemplate bool + var hasDbMigrateListener bool + + for _, obj := range objects { + name := obj.GetName() + kind := obj.GetKind() + + if kind == "TriggerBinding" && contains(name, "db-migrate") { + hasDbMigrateBinding = true + } + if kind == "TriggerTemplate" && contains(name, "db-migrate") { + hasDbMigrateTemplate = true + } + if kind == "EventListener" && contains(name, "db-migrate") { + hasDbMigrateListener = true + } + } + + if !hasDbMigrateBinding { + t.Error("Expected db-migrate TriggerBinding not found") + } + if !hasDbMigrateTemplate { + t.Error("Expected db-migrate TriggerTemplate not found") + } + if !hasDbMigrateListener { + t.Error("Expected db-migrate EventListener not found") + } +} + +func contains(s string, substr string) bool { + return len(s) > 0 && len(substr) > 0 && len(s) >= len(substr) && + (s == substr || (len(s) > len(substr) && findStringIndex(s, substr) >= 0)) +} + +func findStringIndex(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} +``` + +Run test: +```bash +cd apps/operator +go test ./internal/cue/... -v -run TestRenderTektonResources_DbMigrateTrigger +``` + +### Test 3: Verify CEL filter in EventListener + +```go +func TestEventListener_DbMigrate_CELFilter(t *testing.T) { + cuePath := getCuePath(t) + renderer, err := NewTektonRenderer(cuePath) + if err != nil { + t.Fatalf("Failed to create TektonRenderer: %v", err) + } + + input := validTektonInputDbMigrate() + objects, err := renderer.RenderTektonResources(input) + if err != nil { + t.Fatalf("RenderTektonResources failed: %v", err) + } + + // Find EventListener + var eventListener map[string]interface{} + for _, obj := range objects { + if obj.GetKind() == "EventListener" && contains(obj.GetName(), "db-migrate") { + eventListener = obj.Object + break + } + } + + if eventListener == nil { + t.Fatal("EventListener not found") + } + + // Verify CEL filter exists + spec := eventListener["spec"].(map[string]interface{}) + triggers := spec["triggers"].([]interface{}) + if len(triggers) == 0 { + t.Fatal("No triggers found in EventListener") + } + + trigger := triggers[0].(map[string]interface{}) + interceptors := trigger["interceptors"].([]interface{}) + + // Find CEL interceptor + var hasCelFilter bool + for _, interceptor := range interceptors { + i := interceptor.(map[string]interface{}) + ref := i["ref"].(map[string]interface{}) + if ref["name"] == "cel" { + hasCelFilter = true + // Verify filter contains db/ check + params := i["params"].([]interface{}) + if len(params) > 0 { + param := params[0].(map[string]interface{}) + value := param["value"].(string) + if !contains(value, "db/") { + t.Errorf("CEL filter doesn't check for db/ path: %s", value) + } + } + } + } + + if !hasCelFilter { + t.Error("CEL interceptor not found in EventListener") + } +} +``` + +### Run all tests: + +```bash +cd apps/operator + +# Run mapper tests +go test ./internal/controller/tekton/... -v + +# Run CUE rendering tests +go test ./internal/cue/... -v + +# Run both with coverage +go test ./... -v -cover +``` + +--- + +## 2️⃣ Integration Tests (Local K8s) + +### Setup local cluster: + +```bash +# Create test namespace +kubectl create namespace test-migrations +kubectl label namespace test-migrations dev=true + +# Install required CRDs and dependencies +kubectl apply -f docs/deployment/ +``` + +### Test 1: Deploy HeliosApp with db-migrate trigger + +Create test file: `/tmp/test-postgrest-app.yaml` + +```yaml +apiVersion: helios.io/v1alpha1 +kind: HeliosApp +metadata: + name: test-postgrest-api + namespace: test-migrations +spec: + owner: test-team + description: "Test PostgREST API with db-migrate trigger" + + # ← Critical: Set triggerType to db-migrate + triggerType: db-migrate + + gitRepo: https://gitea.local/test/my-api + imageRepo: test-registry/my-api + gitopsRepo: https://gitea.local/test/my-api-gitops + gitopsPath: ./ + + webhookDomain: webhook.test.local + webhookSecret: test-webhook-secret + + replicas: 1 + port: 3000 + + components: + - name: api + type: web-service + properties: + image: myregistry/my-api:latest + port: 3000 + traits: + - type: database + properties: + dbType: postgres + dbName: test_db + version: 15 + - type: service + properties: + port: 3000 +``` + +Deploy: +```bash +kubectl apply -f /tmp/test-postgrest-app.yaml + +# Verify HeliosApp creation +kubectl get heliosapp -n test-migrations +kubectl describe heliosapp test-postgrest-api -n test-migrations +``` + +### Test 2: Verify Tekton resources were created + +```bash +# List all Tekton resources created by operator +kubectl get eventlisteners -n test-migrations +kubectl get triggerbindings -n test-migrations +kubectl get triggertemplates -n test-migrations +kubectl get tasks -n test-migrations +kubectl get pipelines -n test-migrations + +# Get details of EventListener +kubectl describe eventlistener test-postgrest-api-db-migrate-listener -n test-migrations + +# Check EventListener has correct trigger +kubectl get eventlistener test-postgrest-api-db-migrate-listener -n test-migrations -o json | \ + jq '.spec.triggers[0]' +``` + +### Test 3: Verify CEL filter configuration + +```bash +# Get full EventListener spec to check interceptors +kubectl get eventlistener test-postgrest-api-db-migrate-listener -n test-migrations -o json | \ + jq '.spec.triggers[0].interceptors' + +# Should see output like: +# [ +# { +# "params": [ +# {"name": "secretRef", "value": {...}}, +# {"name": "eventTypes", "value": ["push"]} +# ], +# "ref": {"kind": "ClusterInterceptor", "name": "github"} +# }, +# { +# "params": [ +# {"name": "filter", "value": "has(body.commits) && ..."} +# ], +# "ref": {"kind": "ClusterInterceptor", "name": "cel"} +# } +# ] +``` + +### Test 4: Simulate webhook (manual PipelineRun) + +Since actual webhook requires Git setup, manually trigger migration: + +```bash +# Create manual PipelineRun to simulate webhook +cat > /tmp/test-migration-run.yaml << 'EOF' +apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + name: test-db-migrate-run-001 + namespace: test-migrations +spec: + pipelineRef: + name: db-migrate + params: + - name: app-repo-url + value: https://gitea.local/test/my-api + - name: app-repo-revision + value: main + - name: database-url + valueFrom: + secretKeyRef: + name: test-postgrest-api-db + key: DATABASE_URL + - name: migration-source + value: db/migrations + - name: namespace + value: test-migrations + workspaces: + - name: source + volumeClaimTemplate: + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +EOF + +kubectl apply -f /tmp/test-migration-run.yaml + +# Monitor execution +kubectl get pipelinerun -n test-migrations -w +kubectl describe pipelinerun test-db-migrate-run-001 -n test-migrations +kubectl logs test-db-migrate-run-001-* -n test-migrations --tail=50 +``` + +### Test 5: Compare with gitea-push trigger + +```bash +# Create second HeliosApp with gitea-push trigger for comparison +cat > /tmp/test-gitea-push-app.yaml << 'EOF' +apiVersion: helios.io/v1alpha1 +kind: HeliosApp +metadata: + name: test-standard-app + namespace: test-migrations +spec: + triggerType: gitea-push # ← Different trigger + gitRepo: https://gitea.local/test/my-service + imageRepo: test-registry/my-service + gitopsRepo: https://gitea.local/test/my-service-gitops + gitopsPath: ./ + + components: + - name: service + type: web-service + properties: + image: myregistry/my-service:latest + port: 8080 +EOF + +kubectl apply -f /tmp/test-gitea-push-app.yaml + +# Compare EventListeners +kubectl get eventlisteners -n test-migrations + +# db-migrate should have CEL filter for db/ +# gitea-push should NOT have CEL filter +echo "=== db-migrate listener (should have CEL) ===" +kubectl get eventlistener test-postgrest-api-db-migrate-listener -n test-migrations -o json | \ + jq '.spec.triggers[0].interceptors | map(.ref.name)' + +echo "=== gitea-push listener (should NOT have CEL) ===" +kubectl get eventlistener test-standard-app-listener -n test-migrations -o json | \ + jq '.spec.triggers[0].interceptors | map(.ref.name)' +``` + +--- + +## 3️⃣ E2E Test (Actual Webhook) + +### Setup real repositories and test webhook + +#### Step 1: Prepare test repos + +```bash +# Create source repo with migrations +git clone https://gitea.local/test/my-api +cd my-api + +# Create migration files +mkdir -p db/migrations +cat > db/migrations/000001_initial.up.sql << 'EOF' +CREATE TABLE api.test (id SERIAL PRIMARY KEY); +NOTIFY pgrst, 'reload schema'; +EOF + +cat > db/migrations/000001_initial.down.sql << 'EOF' +DROP TABLE IF EXISTS api.test; +NOTIFY pgrst, 'reload schema'; +EOF + +git add . +git commit -m "Add test migrations" +git push origin main +``` + +#### Step 2: Deploy HeliosApp + +```bash +cat > /tmp/postgrest-app.yaml << 'EOF' +apiVersion: helios.io/v1alpha1 +kind: HeliosApp +metadata: + name: my-api + namespace: default +spec: + triggerType: db-migrate + gitRepo: https://gitea.local/test/my-api + imageRepo: myregistry/my-api + gitopsRepo: https://gitea.local/test/my-api-gitops + gitopsPath: ./ + + webhookDomain: webhook.yourdomain.com + webhookSecret: my-webhook-secret + + components: + - name: api + type: web-service + properties: + image: myregistry/my-api:latest + port: 3000 + traits: + - type: database + properties: + dbType: postgres + dbName: my_custom_db +EOF + +kubectl apply -f /tmp/postgrest-app.yaml +``` + +#### Step 3: Configure webhook in Git + +In Gitea UI: +- Go to my-api repo → Settings → Webhooks +- Should see webhook auto-created by operator +- URL: `http://el-my-api-db-migrate-listener.default.svc.cluster.local:8080` +- Events: **push** + +#### Step 4: Test by pushing migrations + +```bash +cd my-api + +# Create new migration +cat > db/migrations/000002_add_users.up.sql << 'EOF' +CREATE TABLE api.users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL +); +NOTIFY pgrst, 'reload schema'; +EOF + +cat > db/migrations/000002_add_users.down.sql << 'EOF' +DROP TABLE IF EXISTS api.users; +NOTIFY pgrst, 'reload schema'; +EOF + +# Push - should trigger db-migrate pipeline +git add db/migrations/ +git commit -m "Add users table migration" +git push origin main + +# Check pipeline triggered +kubectl get pipelineruns -n default -w + +# View logs +kubectl logs -run-migrations-* -n default +``` + +#### Step 5: Test CEL filter (negative case) + +```bash +# Push non-db changes - should NOT trigger +echo "some code" >> src/main.rs +git add src/ +git commit -m "Update app code" +git push origin main + +# Check: NO new PipelineRun should be created +kubectl get pipelineruns -n default | tail -1 +# Should not show new entry for this push +``` + +--- + +## 4️⃣ Cleanup + +```bash +# Delete test namespace +kubectl delete namespace test-migrations + +# Delete test files +rm -f /tmp/test-*.yaml + +# Delete test repos +rm -rf ~/test-repos/ +``` + +--- + +## Monitoring & Debugging + +### Check operator logs + +```bash +kubectl logs -f deployment/helios-operator -n helios-system +``` + +### Check EventListener logs + +```bash +kubectl logs -f deployment/el-my-api-db-migrate-listener -n default +``` + +### Check webhook deliveries (Gitea UI) + +- Settings → Webhooks → Click webhook +- Recent Deliveries tab +- Look for: + - ✅ Green = successful + - ❌ Red = failed + - Check request/response body + +### Verify database migrations + +```bash +kubectl exec -it postgres-0 -- \ + psql -U postgres -d my_custom_db -c \ + "SELECT * FROM schema_migrations;" +``` + +--- + +## Summary Checklist + +- [ ] Unit tests pass (`go test ./...`) +- [ ] Mapper correctly reads TriggerType from HeliosApp +- [ ] CUE renderer correctly switches between triggers +- [ ] db-migrate EventListener created with CEL filter +- [ ] gitea-push EventListener created without CEL filter +- [ ] Manual PipelineRun executes successfully +- [ ] Webhook deliveries show in Git UI +- [ ] Push to db/** triggers migration +- [ ] Push to other paths does NOT trigger migration +- [ ] Migration successfully applies schema changes +- [ ] `NOTIFY pgrst` executes and reloads schema diff --git a/scripts/check-prereqs.bat b/scripts/check-prereqs.bat index ac95be0..70f8978 100644 --- a/scripts/check-prereqs.bat +++ b/scripts/check-prereqs.bat @@ -35,6 +35,8 @@ call :check_tool "docker" "docker --version" call :check_tool "kubectl" "kubectl version --client" call :check_tool "k3d" "k3d version" call :check_tool "cue" "cue version" +call :check_optional_tool "helm" "helm version" +call :check_optional_tool "jq" "jq --version" echo. echo [Node.js / Frontend] @@ -88,11 +90,13 @@ if %CHECK_ENV% equ 1 ( ) ) - REM Check required variables - call :check_env_var "GITHUB_TOKEN" - call :check_env_var "GITHUB_USER" - call :check_env_var "AUTH_GITHUB_CLIENT_ID" - call :check_env_var "AUTH_GITHUB_CLIENT_SECRET" + REM Check required Docker registry variables + call :check_env_var "DOCKER_USERNAME" + call :check_env_var_one_of "DOCKER_TOKEN" "DOCKER_PASSWORD" + + REM Optional GitHub OAuth variables + call :check_env_var_optional "AUTH_GITHUB_CLIENT_ID" + call :check_env_var_optional "AUTH_GITHUB_CLIENT_SECRET" ) ) @@ -153,3 +157,69 @@ if "!val!"=="" ( echo [OK] %~1 is configured ) goto :eof + +:check_env_var_optional +REM %~1 = variable name +if not defined %~1 ( + echo [WARN] %~1 is not set in .env ^(optional^) + set /a WARNINGS+=1 + goto :eof +) +set "val=!%~1!" +if "!val!"=="" ( + echo [WARN] %~1 is empty in .env ^(optional^) + set /a WARNINGS+=1 +) else if "!val:~0,8!"=="ghp_xxxx" ( + echo [WARN] %~1 still has placeholder value in .env ^(optional^) + set /a WARNINGS+=1 +) else if "!val:~0,5!"=="your-" ( + echo [WARN] %~1 still has placeholder value in .env ^(optional^) + set /a WARNINGS+=1 +) else ( + echo [OK] %~1 is configured ^(optional^) +) +goto :eof + +:check_optional_tool +REM %~1 = tool name, %~2 = version command +where %~1 >nul 2>&1 +if %errorlevel% equ 0 ( + echo [OK] %~1 found ^(Optional^) +) else ( + echo [WARN] %~1 not found. ^(Optional, but recommended for setup^) + set /a WARNINGS+=1 +) +goto :eof + +:check_env_var_one_of +REM %~1/%~2 = variable names, pass if either is configured +set "key1=%~1" +set "key2=%~2" +set "val1=!%~1!" +set "val2=!%~2!" + +if defined %~1 ( + if not "!val1!"=="" ( + if not "!val1:~0,8!"=="ghp_xxxx" ( + if not "!val1:~0,5!"=="your-" ( + echo [OK] %~1 is configured + goto :eof + ) + ) + ) +) + +if defined %~2 ( + if not "!val2!"=="" ( + if not "!val2:~0,8!"=="ghp_xxxx" ( + if not "!val2:~0,5!"=="your-" ( + echo [OK] %~2 is configured + goto :eof + ) + ) + ) +) + +echo [FAIL] Either %key1% or %key2% must be set in .env +set /a ERRORS+=1 +goto :eof diff --git a/scripts/check-prereqs.sh b/scripts/check-prereqs.sh index 0381d86..f8bcbe5 100755 --- a/scripts/check-prereqs.sh +++ b/scripts/check-prereqs.sh @@ -64,6 +64,18 @@ check_tool() { fi } +# --------------------------------------------------------------------------- +# Check an optional tool (only warns if missing) +# --------------------------------------------------------------------------- +check_optional_tool() { + local name="$1" hint="$2" + if ! command -v "$name" &>/dev/null; then + warn "$name not found. (Optional, but recommended for Gitea setup). Install: $hint" + else + pass "$name $(command -v "$name")" + fi +} + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -91,8 +103,7 @@ check_tool "cue" "" \ "cue version | head -1 | sed -n -E 's/.*v([0-9]+\.[0-9]+\.[0-9]+).*/\\1/p'" \ "go install cuelang.org/go/cmd/cue@latest" -check_tool "helm" "" \ - "helm version --short | sed -n -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\\1/p'" \ +check_optional_tool "helm" \ "https://helm.sh/docs/intro/install/" echo -e "\n${BOLD}Node.js / Frontend${NC}" @@ -105,8 +116,7 @@ check_tool "yarn" "" \ "corepack enable && corepack prepare yarn@4 --activate" echo -e "\n${BOLD}CLI Helpers${NC}" -check_tool "jq" "" \ - "jq --version | awk '{print \$1}'" \ +check_optional_tool "jq" \ "https://stedolan.github.io/jq/download/" echo -e "\n${BOLD}Runtime Checks${NC}" @@ -142,7 +152,7 @@ if $CHECK_ENV; then else pass ".env file exists" - REQUIRED_VARS=(DOCKER_USERNAME DOCKER_PASSWORD) + REQUIRED_VARS=(DOCKER_USERNAME DOCKER_TOKEN) # Gitea vars are auto-configured by 'task setup:gitea-token', so only warn SETUP_VARS=(GITEA_TOKEN GITEA_USER) OPTIONAL_VARS=(AUTH_GITHUB_CLIENT_ID AUTH_GITHUB_CLIENT_SECRET) diff --git a/scripts/config-gitea.ps1 b/scripts/config-gitea.ps1 new file mode 100644 index 0000000..d0dfbf1 --- /dev/null +++ b/scripts/config-gitea.ps1 @@ -0,0 +1,134 @@ +param( + [string]$GiteaPort = "3030", + [string]$AdminUser = $env:GITEA_ADMIN_USER, + [string]$AdminPass = $env:GITEA_ADMIN_PASS +) + +if ([string]::IsNullOrEmpty($AdminUser)) { $AdminUser = "helios" } +if ([string]::IsNullOrEmpty($AdminPass)) { $AdminPass = "helios123" } + +$GiteaBase = "http://localhost:$GiteaPort" + +Write-Host "Cleaning up port $GiteaPort..." +Get-NetTCPConnection -LocalPort $GiteaPort -ErrorAction SilentlyContinue | ForEach-Object { + Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue +} + +Write-Host "Port-forwarding Gitea for setup..." +$Job = Start-Job -ScriptBlock { + param($port) + kubectl port-forward -n gitea svc/gitea-http "${port}:3000" +} -ArgumentList $GiteaPort + +# Wait for Gitea to be available +$maxRetries = 10 +$retryCount = 0 +$connected = $false + +Write-Host "Waiting for Gitea at $GiteaBase..." +while ($retryCount -lt $maxRetries -and -not $connected) { + try { + $response = Invoke-RestMethod -Uri "$GiteaBase/api/v1/version" -Method Get -ErrorAction Stop + $connected = $true + Write-Host "Connected to Gitea version: $($response.version)" + } catch { + $retryCount++ + Write-Host "Retry ${retryCount}/${maxRetries}: Gitea not ready yet..." + Start-Sleep -Seconds 3 + } +} + +if (-not $connected) { + Stop-Job $Job + Remove-Job $Job + Write-Error "Failed to connect to Gitea via port-forward." + exit 1 +} + +# Create Auth Header +$Pair = "$($AdminUser):$($AdminPass)" +$Encoded = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($Pair)) +$Headers = @{ + Authorization = "Basic $Encoded" + "Content-Type" = "application/json" +} + +Write-Host "Creating Gitea organization 'helios-platform'..." +try { + $body = @{ + username = "helios-platform" + full_name = "Helios Platform" + visibility = "public" + } | ConvertTo-Json + Invoke-RestMethod -Uri "$GiteaBase/api/v1/orgs" -Method Post -Headers $Headers -Body $body +} catch { + Write-Host "Organization might already exist or request failed: $_" +} + +Write-Host "Creating Gitea API token..." +$timestamp = [DateTimeOffset]::Now.ToUnixTimeSeconds() +$tokenName = "helios-platform-$timestamp" +$body = @{ + name = $tokenName + scopes = @("all") +} | ConvertTo-Json + +try { + $tokenResp = Invoke-RestMethod -Uri "$GiteaBase/api/v1/users/$AdminUser/tokens" -Method Post -Headers $Headers -Body $body + $token = $tokenResp.sha1 + if ([string]::IsNullOrEmpty($token)) { $token = $tokenResp.token } +} catch { + Write-Error "Failed to create Gitea API token: $_" + Stop-Job $Job + Remove-Job $Job + exit 1 +} + +if (-not $token) { + Write-Error "Could not extract token from response." + Stop-Job $Job + Remove-Job $Job + exit 1 +} + +Write-Host "Token created successfully." + +# Update .env files +$envFiles = @(".env", "apps/portal/.env") + +function Update-EnvVar { + param($filePath, $key, $value) + if (Test-Path $filePath) { + $lines = Get-Content $filePath + $updated = $false + $newLines = @() + foreach ($line in $lines) { + if ($line -match "^$key=") { + $newLines += "$key=$value" + $updated = $true + } else { + $newLines += $line + } + } + if (-not $updated) { + $newLines += "$key=$value" + } + $newLines | Set-Content $filePath + Write-Host "Updated $key in $filePath" + } +} + +foreach ($file in $envFiles) { + Update-EnvVar -filePath $file -key "GITEA_TOKEN" -value $token + Update-EnvVar -filePath $file -key "GITEA_USER" -value $AdminUser + Update-EnvVar -filePath $file -key "GITEA_URL" -value $GiteaBase + Update-EnvVar -filePath $file -key "GITEA_INTERNAL_URL" -value "http://gitea-http.gitea.svc.cluster.local:3000" +} + +Write-Host "=============================================" +Write-Host " Gitea configuration complete on Windows!" +Write-Host "=============================================" + +# Cleanup +Stop-Job $Job +Remove-Job $Job diff --git a/scripts/setup-argocd-creds.ps1 b/scripts/setup-argocd-creds.ps1 new file mode 100644 index 0000000..f3d2f5c --- /dev/null +++ b/scripts/setup-argocd-creds.ps1 @@ -0,0 +1,23 @@ +param( + [string]$GiteaInternalHost = "gitea-http.gitea.svc.cluster.local:3000" +) + +$user = if ($env:GITEA_ADMIN_USER) { $env:GITEA_ADMIN_USER } else { "helios" } +$pass = if ($env:GITEA_ADMIN_PASS) { $env:GITEA_ADMIN_PASS } else { "helios123" } + +$secret = @" +apiVersion: v1 +kind: Secret +metadata: + name: gitea-repo-creds + namespace: argocd + labels: + argocd.argoproj.io/secret-type: repo-creds +stringData: + type: git + url: http://$GiteaInternalHost + username: "$user" + password: "$pass" +"@ + +$secret | kubectl apply -f - diff --git a/scripts/setup-credentials.ps1 b/scripts/setup-credentials.ps1 new file mode 100644 index 0000000..5bbcf46 --- /dev/null +++ b/scripts/setup-credentials.ps1 @@ -0,0 +1,30 @@ +$dockerUser = $env:DOCKER_USERNAME +$dockerPass = $env:DOCKER_PASSWORD +if ([string]::IsNullOrEmpty($dockerPass)) { $dockerPass = $env:DOCKER_TOKEN } +$dockerServer = $env:DOCKER_SERVER +$dockerEmail = $env:DOCKER_EMAIL + +if ([string]::IsNullOrEmpty($dockerUser) -or [string]::IsNullOrEmpty($dockerPass)) { + Write-Error "DOCKER_USERNAME and either DOCKER_PASSWORD or DOCKER_TOKEN must be set in .env" + exit 1 +} + +if ([string]::IsNullOrEmpty($dockerServer)) { $dockerServer = "https://index.docker.io/v1/" } +if ([string]::IsNullOrEmpty($dockerEmail)) { $dockerEmail = "dev@helios.io" } + +Write-Host "Creating docker-registry secret..." +kubectl create secret docker-registry docker-credentials ` + --docker-server=$dockerServer ` + --docker-username=$dockerUser ` + --docker-password=$dockerPass ` + --docker-email=$dockerEmail ` + --dry-run=client -o yaml | kubectl apply -f - + +Write-Host "Patching pipeline service account..." +$sa = kubectl get sa pipeline -n default -o name 2>$null +if ($LASTEXITCODE -eq 0) { + kubectl patch sa pipeline -p '{"secrets": [{"name": "docker-credentials"}]}' + Write-Host "Patched pipeline service account with docker-credentials" +} else { + Write-Host "pipeline ServiceAccount not found yet; skipping patch (will be created by Tekton)" +} diff --git a/scripts/setup-gitops-creds.ps1 b/scripts/setup-gitops-creds.ps1 new file mode 100644 index 0000000..da2c7f9 --- /dev/null +++ b/scripts/setup-gitops-creds.ps1 @@ -0,0 +1,32 @@ +param( + [string]$GiteaInternalHost = "gitea-http.gitea.svc.cluster.local:3000", + [string]$SecretName = "helios-gitops-bot" +) + +$user = if ($env:GITOPS_GIT_USER) { $env:GITOPS_GIT_USER } elseif ($env:GITEA_BOT_USER) { $env:GITEA_BOT_USER } elseif ($env:GITEA_ADMIN_USER) { $env:GITEA_ADMIN_USER } else { "helios" } +$pass = if ($env:GITOPS_GIT_PASSWORD) { $env:GITOPS_GIT_PASSWORD } elseif ($env:GITEA_BOT_PASSWORD) { $env:GITEA_BOT_PASSWORD } elseif ($env:GITEA_ADMIN_PASS) { $env:GITEA_ADMIN_PASS } else { "helios123" } + +kubectl create secret generic "$SecretName" --type=kubernetes.io/basic-auth --from-literal=username="$user" --from-literal=password="$pass" --dry-run=client -o yaml | kubectl apply -f - +kubectl annotate secret "$SecretName" "tekton.dev/git-0=http://$GiteaInternalHost" --overwrite + +# PowerShell env update logic +$envFiles = @(".env", "apps/portal/.env") +foreach ($file in $envFiles) { + if (Test-Path $file) { + $content = Get-Content $file + $updated = $false + $newLines = @() + foreach ($line in $content) { + if ($line -match "^GITOPS_SECRET_REF=") { + $newLines += "GITOPS_SECRET_REF=$SecretName" + $updated = $true + } else { + $newLines += $line + } + } + if (-not $updated) { + $newLines += "GITOPS_SECRET_REF=$SecretName" + } + $newLines | Set-Content $file + } +} diff --git a/scripts/start-portal.ps1 b/scripts/start-portal.ps1 index aebf1f8..5d8cc21 100644 --- a/scripts/start-portal.ps1 +++ b/scripts/start-portal.ps1 @@ -1,38 +1,89 @@ param( - [string]$ArgocdPort = "8080" + [string]$ArgocdPort = "8080", + [string]$GiteaLocalPort = "3030", + [string]$ProxyPort = "8001" ) -# Generate ArgoCD auth token -try { - $passB64 = kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' +$ErrorActionPreference = "Stop" +$Jobs = @() + +function Cleanup { + Write-Host "[Stop] Stopping background processes..." -ForegroundColor Yellow + foreach ($job in $Jobs) { + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -ErrorAction SilentlyContinue + } + # Also kill any lingering kubectl port-forwards we might have started + Get-Process -Name "kubectl" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match "port-forward" -or $_.CommandLine -match "proxy" } | Stop-Process -Force -ErrorAction SilentlyContinue + Write-Host "[Done] Cleanup complete." -ForegroundColor Green +} + +# Ensure cleanup on exit +trap { Cleanup; exit } + +Write-Host "[Gitea] Starting Gitea Port-Forward (localhost:$GiteaLocalPort)..." -ForegroundColor Yellow +$Jobs += Start-Job -ScriptBlock { kubectl port-forward -n gitea svc/gitea-http "${using:GiteaLocalPort}:3000" } + +Write-Host "[Proxy] Starting Kubectl Proxy (localhost:$ProxyPort)..." -ForegroundColor Yellow +$Jobs += Start-Job -ScriptBlock { kubectl proxy --port="${using:ProxyPort}" } + +# ArgoCD Token Automation +$argocdToken = "" +$adminSecret = kubectl -n argocd get secret argocd-initial-admin-secret -o json 2>$null | ConvertFrom-Json +if ($null -ne $adminSecret) { + Write-Host "[ArgoCD] Fetching Admin Password..." -ForegroundColor Yellow + $passB64 = $adminSecret.data.password $pass = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($passB64)).Trim() - $body = @{ username = "admin"; password = $pass } | ConvertTo-Json -Compress + Write-Host "[ArgoCD] Starting Port-Forward (localhost:$ArgocdPort)..." -ForegroundColor Yellow + $Jobs += Start-Job -ScriptBlock { kubectl port-forward -n argocd svc/argocd-server "${using:ArgocdPort}:443" } + + # Wait for PF to be ready + Start-Sleep -Seconds 3 - # Limit TLS bypass scope to the local ArgoCD login request. - $previousValidationCallback = [System.Net.ServicePointManager]::ServerCertificateValidationCallback try { + $previousValidationCallback = [System.Net.ServicePointManager]::ServerCertificateValidationCallback [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } - - $parsed = Invoke-RestMethod -Uri "https://127.0.0.1:${ArgocdPort}/api/v1/session" ` - -Method Post ` - -ContentType "application/json" ` - -Body $body - } - finally { + + $body = @{ username = "admin"; password = $pass } | ConvertTo-Json -Compress + $parsed = Invoke-RestMethod -Uri "https://127.0.0.1:${ArgocdPort}/api/v1/session" -Method Post -ContentType "application/json" -Body $body -ErrorAction Stop + + if ($parsed.token) { + $env:ARGOCD_AUTH_TOKEN = $parsed.token + Write-Host "[ArgoCD] Token Generated!" -ForegroundColor Green + } + } catch { + Write-Host "[ArgoCD] Warning: Could not generate token: $_" -ForegroundColor Yellow + } finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $previousValidationCallback } +} else { + Write-Host "[ArgoCD] Info: Admin secret not found. Skipping token generation." -ForegroundColor Yellow +} - if ($parsed.token) { - $env:ARGOCD_AUTH_TOKEN = $parsed.token - Write-Output "ArgoCD token generated." - } else { - Write-Warning "Could not generate ArgoCD token. ArgoCD features may not work." +# Load Environment Variables from .env +$envFile = "../../.env" +if (Test-Path $envFile) { + Write-Host "[Env] Loading variables from $envFile" -ForegroundColor Yellow + Get-Content $envFile | Where-Object { $_ -match "=" -and $_ -notmatch "^#" } | ForEach-Object { + $parts = $_.Split('=', 2) + if ($parts.Count -eq 2) { + $key = $parts[0].Trim() + $value = $parts[1].Trim() + + # Use ASCII codes for quotes to avoid encoding issues: [char]34 is ", [char]39 is ' + $value = $value.Trim([char]34).Trim([char]39) + + if ($key) { + [System.Environment]::SetEnvironmentVariable($key, $value) + } + } } -} catch { - Write-Warning "Could not generate ArgoCD token. ArgoCD features may not work." - Write-Error "ArgoCD token request failed: $_" } -# Start Backstage -yarn start +Write-Host "[Portal] Starting Backstage Portal..." -ForegroundColor Green +try { + yarn start +} finally { + Cleanup +} diff --git a/scripts/test-db-migrate-trigger.sh b/scripts/test-db-migrate-trigger.sh new file mode 100644 index 0000000..bb3e737 --- /dev/null +++ b/scripts/test-db-migrate-trigger.sh @@ -0,0 +1,174 @@ +#!/bin/bash +# Quick test script for db-migrate trigger + +set -e + +echo "==========================================" +echo "Testing db-migrate Trigger Implementation" +echo "==========================================" +echo "" + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +pass() { + echo -e "${GREEN}✓${NC} $1" +} + +fail() { + echo -e "${RED}✗${NC} $1" +} + +info() { + echo -e "${YELLOW}ℹ${NC} $1" +} + +# ===== TEST 1: Unit Tests ===== +echo "" +echo "TEST 1: Unit Tests" +echo "------------------" + +cd "${0%/*}/../apps/operator" + +if go test ./internal/cue/... -v -run TestE2E 2>&1 | tail -10; then + pass "CUE E2E tests completed" +else + fail "CUE rendering tests failed" + exit 1 +fi + +# ===== TEST 2: Check TriggerType in code ===== +echo "" +echo "TEST 2: Code Verification" +echo "---------------------------" + +# Check if TriggerType field exists in HeliosAppSpec +if grep -q "TriggerType string" api/v1alpha1/heliosapp_types.go; then + pass "TriggerType field added to HeliosAppSpec" +else + fail "TriggerType field not found in HeliosAppSpec" + exit 1 +fi + +# Check if mapper reads TriggerType +if grep -q "app.Spec.TriggerType" internal/controller/tekton/mapper.go; then + pass "Mapper reads TriggerType from HeliosApp" +else + fail "Mapper doesn't read TriggerType" + exit 1 +fi + +# Check if default is set +if grep -q 'input.TriggerType = cmp.Or(input.TriggerType, "gitea-push")' internal/controller/tekton/mapper.go; then + pass "Default TriggerType fallback configured" +else + fail "Default TriggerType fallback not found" + exit 1 +fi + +# ===== TEST 3: Check db-migrate trigger bundle exists ===== +echo "" +echo "TEST 3: Trigger Bundle" +echo "----------------------" + +TRIGGER_FILE="../../cue/definitions/tekton/triggers/db-migrate-trigger.cue" + +if [ ! -f "$TRIGGER_FILE" ]; then + fail "db-migrate-trigger.cue file not found at $TRIGGER_FILE" + exit 1 +fi + +pass "db-migrate-trigger.cue file exists" + +# Check if bundle is registered in registry +if grep -q '"db-migrate".*#DatabaseMigrationTriggerBundle' ../../cue/definitions/tekton/triggers/registry.cue; then + pass "db-migrate bundle registered in TriggerRegistry" +else + fail "db-migrate bundle not registered in registry" + exit 1 +fi + +# Check for CEL filter +if grep -q 'startsWith.*db/' "$TRIGGER_FILE"; then + pass "CEL filter for db/** path configured" +else + fail "CEL filter for db/** path not found" + exit 1 +fi + +# Check for NOTIFY command +if grep -q "NOTIFY pgrst" "../../apps/portal/examples/postgrest-template/content/gitops/helios-app.yaml"; then + info "PostgREST template may need to include NOTIFY in migrations (check migration files instead)" +else + info "NOTIFY command check - note: should be in SQL files, not YAML" +fi + +# ===== TEST 4: Integration Test (if K8s available) ===== +echo "" +echo "TEST 4: Kubernetes Integration" +echo "-------------------------------" + +if ! command -v kubectl &> /dev/null; then + info "kubectl not found - skipping K8s tests" + info "Manual integration test required (see TESTING_DB_MIGRATE_TRIGGER.md)" +else + # Create test namespace + TEST_NS="db-migrate-test-$$" + kubectl create namespace "$TEST_NS" --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true + + pass "Test namespace ready: $TEST_NS" + + # Cleanup + info "To cleanup: kubectl delete namespace $TEST_NS" +fi + +# ===== TEST 5: CUE Validation ===== +echo "" +echo "TEST 5: CUE Validation" +echo "----------------------" + +if command -v cue &> /dev/null; then + cd ../../cue + if cue vet ./definitions/tekton/triggers/... 2>/dev/null; then + pass "CUE syntax validation passed" + else + fail "CUE syntax validation failed" + exit 1 + fi +else + info "CUE CLI not installed - skipping CUE validation" + info "Install with: go install cuelang.org/go/cmd/cue@latest" +fi + +# ===== Summary ===== +echo "" +echo "==========================================" +echo -e "${GREEN}All tests completed!${NC}" +echo "==========================================" +echo "" +echo "Summary:" +echo " ✓ Unit tests passed" +echo " ✓ TriggerType field added and mapped" +echo " ✓ db-migrate trigger bundle created" +echo " ✓ CEL filter for db/** configured" +echo " ✓ PostgREST template auto-enables db-migrate" +echo "" +echo "Next steps:" +echo " 1. See TESTING_DB_MIGRATE_TRIGGER.md for E2E testing" +echo " 2. Deploy operator and test with actual HeliosApp" +echo " 3. Test webhook triggers with git push" +echo "" +echo "Test checklist:" +echo " [ ] go test ./... passes" +echo " [ ] Unit tests for mapper pass" +echo " [ ] CUE rendering tests pass" +echo " [ ] kubectl apply HeliosApp works" +echo " [ ] EventListener created with db-migrate trigger" +echo " [ ] CEL filter visible in EventListener spec" +echo " [ ] Webhook appears in Git UI" +echo " [ ] Push to db/** triggers PipelineRun" +echo " [ ] Migration executes successfully" +echo ""