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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/operator/api/v1alpha1/heliosapp_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions apps/operator/config/crd/bases/app.helios.io_heliosapps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,13 @@ spec:
testCommand:
description: TestCommand is the command to run tests (e.g. "npm test")
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
Expand Down
3 changes: 2 additions & 1 deletion apps/operator/internal/controller/database/injection.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ func connectionURLTemplateForDBType(dbType string) (template string, ok bool) {
}

// 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.
Expand Down
25 changes: 13 additions & 12 deletions apps/operator/internal/controller/database/injection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,15 @@ func TestInjectDatabaseEnvVars(t *testing.T) {
}

container := deploy.Spec.Template.Spec.Containers[0]
if len(container.Env) != 7 {
t.Fatalf("Expected 7 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 {
Expand Down Expand Up @@ -143,8 +144,8 @@ func TestInjectDatabaseEnvVars(t *testing.T) {
}

container := deploy.Spec.Template.Spec.Containers[0]
if len(container.Env) != 6 {
t.Fatalf("Expected 6 env vars (no DATABASE_URL), got %d", len(container.Env))
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" {
Expand Down Expand Up @@ -217,8 +218,8 @@ func TestInjectDatabaseEnvVars(t *testing.T) {
}

container := deploy.Spec.Template.Spec.Containers[0]
if len(container.Env) != 7 {
t.Fatalf("Expected 7 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 {
Expand Down Expand Up @@ -290,7 +291,7 @@ 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,
"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 {
Expand Down Expand Up @@ -334,8 +335,8 @@ func TestInjectDatabaseEnvVars(t *testing.T) {
if exactMatch {
t.Fatal("Expected fallback because preferred container does not exist")
}
if len(deploy.Spec.Template.Spec.Containers[0].Env) != 6 {
t.Fatalf("Expected 6 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))
}
})

Expand Down
17 changes: 15 additions & 2 deletions apps/operator/internal/controller/database/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
31 changes: 27 additions & 4 deletions apps/operator/internal/controller/database/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package database

import (
"fmt"
"net/url"
"strings"

appsv1 "k8s.io/api/apps/v1"
Expand All @@ -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
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",
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,
Expand All @@ -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),
},
}
}
Expand Down
77 changes: 76 additions & 1 deletion apps/operator/internal/controller/database/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
{
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",
},
{
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",
},
{
name: "custom port",
username: "admin",
password: "secret123",
host: "db-instance",
dbName: "prod_db",
port: 5433,
expected: "postgres://admin:secret123@db-instance:5433/prod_db",
},
{
name: "IPv6 host",
username: "user",
password: "pass",
host: "::1",
dbName: "testdb",
port: 5432,
expected: "postgres://user:pass@::1:5432/testdb",
},
}

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)
Expand Down Expand Up @@ -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"
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)
}
Expand Down
3 changes: 2 additions & 1 deletion apps/operator/internal/controller/tekton/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func MapCRDToTektonInput(app *appv1alpha1.HeliosApp) cueModel.TektonInput {
// PipelineType is intentionally derived from PipelineName because
// the HeliosApp CRD does not have a separate PipelineType field.
PipelineType: app.Spec.PipelineName,
TriggerType: "gitea-push",
TriggerType: app.Spec.TriggerType,
ServiceAccount: app.Spec.ServiceAccount,
PVCName: app.Spec.PVCName,
ContextSubpath: app.Spec.ContextSubpath,
Expand All @@ -44,6 +44,7 @@ func MapCRDToTektonInput(app *appv1alpha1.HeliosApp) cueModel.TektonInput {
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")
input.TriggerType = cmp.Or(input.TriggerType, "gitea-push")
if input.PipelineName == "" {
input.PipelineName = defaultPipelineName
input.PipelineType = defaultPipelineName
Expand Down
4 changes: 2 additions & 2 deletions apps/operator/internal/cue/e2e_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions apps/operator/internal/cue/tekton_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ 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 11 objects:
// 6 Tasks (git-clone, kaniko-build, git-update-manifest, argocd-sync, db-migrate, postgrest-reload) + 1 Pipeline + 1 TriggerBinding + 1 TriggerTemplate + 1 EventListener + 1 Ingress
expectedCount := 11
if len(objects) != expectedCount {
t.Errorf("Expected %d objects, got %d", expectedCount, len(objects))
for i, obj := range objects {
Expand All @@ -79,7 +79,7 @@ func TestRenderTektonResources_AllResources(t *testing.T) {

// Verify each expected kind is present
expectedKinds := map[string]int{
"Task": 4,
"Task": 6,
"Pipeline": 1,
"TriggerBinding": 1,
"TriggerTemplate": 1,
Expand Down Expand Up @@ -115,8 +115,8 @@ func TestRenderTektonResources_WithoutWebhook(t *testing.T) {
t.Fatalf("RenderTektonResources failed: %v", err)
}

// Without webhookDomain: 8 objects (no Ingress)
expectedCount := 8
// Without webhookDomain: 10 objects (no Ingress)
expectedCount := 10
if len(objects) != expectedCount {
t.Errorf("Expected %d objects (no webhook), got %d", expectedCount, len(objects))
for i, obj := range objects {
Expand Down Expand Up @@ -174,6 +174,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 {
Expand Down
6 changes: 6 additions & 0 deletions apps/portal/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ catalog:
rules:
- allow: [Template]

# PostgREST Template - Instant REST API over PostgreSQL
- type: file
target: ../../examples/postgrest-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
Expand Down
Loading
Loading