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
5 changes: 5 additions & 0 deletions apps/operator/api/v1alpha1/heliosapp_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ type HeliosAppSpec struct {
// +optional
ContextSubpath string `json:"contextSubpath,omitempty"`

// DatabaseSecretRef is the name of the secret containing database credentials for migrations
// +optional
// +kubebuilder:default="api-db-secret"
DatabaseSecretRef string `json:"databaseSecretRef,omitempty"`

// Components define the workloads of the application
Components []Component `json:"components"`
}
Expand Down
4 changes: 2 additions & 2 deletions apps/operator/internal/controller/database/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ 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
// 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",
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
enscodedUser,
enscodedPassword,
host,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func TestGenerateDatabaseSecret(t *testing.T) {
}

// Check that PGRST_DB_URI is generated and properly escaped
expectedURI := "postgres://testuser:testpassword123@my-app-db:5432/my-app-db"
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"]))
}
Expand Down
38 changes: 37 additions & 1 deletion apps/operator/internal/controller/heliosapp_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
2 changes: 2 additions & 0 deletions apps/operator/internal/controller/tekton/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func MapCRDToTektonInput(app *appv1alpha1.HeliosApp) cueModel.TektonInput {
Port: int(app.Spec.Port),
TestCommand: app.Spec.TestCommand,
DockerSecret: "docker-credentials",
DatabaseSecretRef: app.Spec.DatabaseSecretRef,
ArgoCDNamespace: app.Spec.ArgoCDNamespace,
ArgoCDProject: app.Spec.ArgoCDProject,
}
Expand All @@ -44,6 +45,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.DatabaseSecretRef = cmp.Or(input.DatabaseSecretRef, "api-db-secret")
input.TriggerType = cmp.Or(input.TriggerType, "gitea-push")
if input.PipelineName == "" {
input.PipelineName = defaultPipelineName
Expand Down
13 changes: 11 additions & 2 deletions apps/operator/internal/controller/tekton/pipelinerun.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ func GeneratePipelineRun(heliosApp *appv1alpha1.HeliosApp, pipelineName string)
gitOpsSecretRef := cmp.Or(heliosApp.Spec.GitOpsSecretRef, "helios-gitops-bot")
argoNS := cmp.Or(heliosApp.Spec.ArgoCDNamespace, "argocd")

replicas := heliosApp.Spec.Replicas
if replicas <= 0 {
replicas = 1
}
port := heliosApp.Spec.Port
if port <= 0 || port > 65535 {
port = 8080
}

params := make([]any, 0, 17)
params = append(params,
map[string]any{"name": "app-repo-url", "value": shared.RewriteGiteaURL(heliosApp.Spec.GitRepo)},
Expand All @@ -38,8 +47,8 @@ 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": "argocd-namespace", "value": argoNS},
map[string]any{"name": "argocd-app-name", "value": heliosApp.Name + "-argocd"},
Expand Down
3 changes: 2 additions & 1 deletion apps/operator/internal/cue/tekton.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ type TektonInput struct {
TestCommand string `json:"testCommand,omitempty"`

// === SECRETS ===
DockerSecret string `json:"dockerSecret,omitempty"`
DockerSecret string `json:"dockerSecret,omitempty"`
DatabaseSecretRef string `json:"databaseSecretRef,omitempty"`

// === ARGOCD ===
ArgoCDNamespace string `json:"argoCDNamespace,omitempty"`
Expand Down
Loading