Skip to content
Closed
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
13 changes: 9 additions & 4 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ jobs:
strategy:
fail-fast: false
matrix:
database-provider: [deployment, cnpg, external]
database-provider: [deployment, cnpg, external, secret]
# This job now also absorbs the Konflux build wait that plan-images used to
# hold: kind-up runs first and overlaps the remote build, the
# wait-on-check-action steps then block for whatever build time is left (up
Expand Down Expand Up @@ -404,7 +404,7 @@ jobs:
env:
# Exercise both the default per-gateway Deployment path and the
# explicitly selected CNPG path through the full E2E workflow.
DATABASE_PROVIDER: ${{ matrix.database-provider }}
DATABASE_PROVIDER: ${{ matrix.database-provider == 'secret' && 'deployment' || matrix.database-provider }}
KIND_ENABLE_OIDC: "true"
# Deploy Jaeger and point the web-console BFF at it so the browser
# trace verification below has a collector to export to (WEB-TRACE-10).
Expand Down Expand Up @@ -499,14 +499,18 @@ jobs:
make kind-env
echo "::endgroup::"

- name: Configure controller database admin Secret
if: matrix.database-provider == 'secret'
run: bash tests/fixtures/controller-database/install-kind.sh

# Seed platform resources now that the working-tree images are live (the
# swap above rolled them in). Deferred from kind-up so the seed exercises
# this PR's request contract instead of the baseline placeholder image.
# SEED_STRICT fails the job here with the real HTTP error if a create
# is rejected, rather than surfacing later as a confusing discovery miss.
- name: Seed platform resources
env:
DATABASE_PROVIDER: ${{ matrix.database-provider }}
DATABASE_PROVIDER: ${{ matrix.database-provider == 'secret' && 'deployment' || matrix.database-provider }}
SEED_STRICT: "true"
run: make kind-seed

Expand All @@ -517,7 +521,8 @@ jobs:
# Test the candidate installer before its public main URL is available.
OPENSHELL_INSTALL_SCRIPT_URL: file://${{ github.workspace }}/scripts/install-openshell.sh
# Mirror the provider used to create this matrix job's cluster.
DATABASE_PROVIDER: ${{ matrix.database-provider }}
DATABASE_PROVIDER: ${{ matrix.database-provider == 'secret' && 'deployment' || matrix.database-provider }}
GATEWAY_DATABASE_ADMIN_SECRET_NAME: ${{ matrix.database-provider == 'secret' && 'gateway-database-admin' || '' }}
E2E_PROVISION_TIMEOUT: "300"
E2E_SANDBOX_TIMEOUT: "180"
# Force plain, non-graphical output from the openshell CLI. Off a TTY
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ jobs:
- name: Run control plane unit tests
working-directory: components/control-plane
run: go test -count=1 ./...
- name: Test controller database Secret with PostgreSQL TLS
env:
CONTAINER_ENGINE: docker
run: bash scripts/test-controller-database-secret.sh

test-cli:
name: Go - CLI
Expand Down
19 changes: 14 additions & 5 deletions components/control-plane/cmd/hypershell-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ func registerWithBackoff(ctx context.Context, regClient *registration.Client) (s
// API server or apiserver cannot delay the GC reconciler's launch indefinitely.
const instanceLabelBackfillTimeout = 2 * time.Minute

func managedDatabaseWatchEligible(clientset *kubernetes.Clientset, dynamicClient dynamic.Interface) bool {
return clientset != nil && dynamicClient != nil
func managedDatabaseWatchEligible(clientset *kubernetes.Clientset, dynamicClient dynamic.Interface, databaseSecret string) bool {
return databaseSecret == "" && clientset != nil && dynamicClient != nil
}

func main() {
Expand Down Expand Up @@ -200,14 +200,18 @@ func main() {
}
}

if cfg.GatewayDatabaseAdminSecretName != "" && (clientset == nil || dynamicClient == nil) {
log.Fatalf("GATEWAY_DATABASE_ADMIN_SECRET_NAME requires Kubernetes clients")
}

// DATABASE_PROVIDER=cnpg is a hard startup precondition: the control plane
// must fail cleanly here, before any watch/reconcile loop starts, when the
// exact CNPG API resources this codebase depends on (clusters, databases,
// databaseroles in postgresql.cnpg.io/v1) are not served, rather than
// deferring the failure to the first CNPG-backed reconciliation deep
// inside the gateway/database reconcilers. DATABASE_PROVIDER=deployment (the
// default) never reaches this check and has no CNPG dependency at all.
if cfg.DatabaseProvider == config.DatabaseProviderCNPG {
if cfg.GatewayDatabaseAdminSecretName == "" && cfg.DatabaseProvider == config.DatabaseProviderCNPG {
if clientset == nil {
log.Fatalf("DATABASE_PROVIDER=cnpg requires an in-cluster Kubernetes client to verify the CNPG API prerequisites")
}
Expand Down Expand Up @@ -249,8 +253,10 @@ func main() {

clusterReconciler := reconciler.NewManagedClusterReconciler()
var databaseReconciler watcher.Handler[*pb.ManagedDatabase]
if managedDatabaseWatchEligible(clientset, dynamicClient) {
if managedDatabaseWatchEligible(clientset, dynamicClient, cfg.GatewayDatabaseAdminSecretName) {
databaseReconciler = reconciler.NewManagedDatabaseReconciler(dynamicClient, clientset, conn, cfg.Namespace)
} else if cfg.GatewayDatabaseAdminSecretName != "" {
log.Printf("INFO ManagedDatabase watch disabled: GATEWAY_DATABASE_ADMIN_SECRET_NAME selects %s/%s", cfg.Namespace, cfg.GatewayDatabaseAdminSecretName)
} else {
log.Printf("WARN ManagedDatabase watch disabled: both Kubernetes typed and dynamic clients are required")
}
Expand Down Expand Up @@ -299,8 +305,11 @@ func main() {
var gatewayReconciler watcher.Handler[*pb.Gateway]

if clientset != nil && dynamicClient != nil {
gr, grErr := reconciler.NewGatewayReconciler(dynamicClient, clientset, conn, manifestsDir, cfg.Namespace, keycloakConfig, exposurePort)
gr, grErr := reconciler.NewGatewayReconciler(dynamicClient, clientset, conn, manifestsDir, cfg.Namespace, keycloakConfig, exposurePort, cfg.GatewayDatabaseAdminSecretName)
if grErr != nil {
if cfg.GatewayDatabaseAdminSecretName != "" {
log.Fatalf("initialize gateway reconciler with GATEWAY_DATABASE_ADMIN_SECRET_NAME: %v", grErr)
}
log.Printf("WARN gateway reconciler disabled: %v", grErr)
gatewayReconciler = reconciler.NewStubGatewayReconciler()
} else {
Expand Down
10 changes: 9 additions & 1 deletion components/control-plane/cmd/hypershell-controller/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,17 @@ func TestManagedDatabaseWatchEligible(t *testing.T) {
if !tt.dynamic {
gotDynamic = nil
}
if got := managedDatabaseWatchEligible(gotTyped, gotDynamic); got != tt.want {
if got := managedDatabaseWatchEligible(gotTyped, gotDynamic, ""); got != tt.want {
t.Fatalf("eligible = %v, want %v", got, tt.want)
}
})
}
}

func TestManagedDatabaseWatchDisabledWithSecret(t *testing.T) {
typed := &kubernetes.Clientset{}
dynamic := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
if managedDatabaseWatchEligible(typed, dynamic, "gateway-postgres") {
t.Fatal("ManagedDatabase watch must be disabled with a controller database Secret")
}
}
18 changes: 17 additions & 1 deletion components/control-plane/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"time"

"k8s.io/apimachinery/pkg/util/validation"
)

// Database provider values for DATABASE_PROVIDER. DatabaseProviderDeployment
Expand Down Expand Up @@ -84,6 +86,10 @@ type Config struct {
// (see internal/reconciler.ManagedDatabaseReconciler), so gateways backed
// by CNPG remain compatible even when this default is "deployment".
DatabaseProvider string

// GatewayDatabaseAdminSecretName names the admin Secret in Namespace.
// A non-empty value selects the controller-local database path.
GatewayDatabaseAdminSecretName string
}

func Load() (*Config, error) {
Expand All @@ -107,7 +113,17 @@ func Load() (*Config, error) {

GatewayReconcileWorkers: getEnvInt("GATEWAY_RECONCILE_WORKERS", DefaultGatewayReconcileWorkers, 1),

DatabaseProvider: databaseProvider,
DatabaseProvider: databaseProvider,
GatewayDatabaseAdminSecretName: os.Getenv("GATEWAY_DATABASE_ADMIN_SECRET_NAME"),
}

if cfg.GatewayDatabaseAdminSecretName != "" {
if problems := validation.IsDNS1123Subdomain(cfg.GatewayDatabaseAdminSecretName); len(problems) != 0 {
return nil, fmt.Errorf("invalid GATEWAY_DATABASE_ADMIN_SECRET_NAME: %s", strings.Join(problems, "; "))
}
if problems := validation.IsDNS1123Label(cfg.Namespace); len(problems) != 0 {
return nil, fmt.Errorf("invalid HYPERSHELL_NAMESPACE for GATEWAY_DATABASE_ADMIN_SECRET_NAME: %s", strings.Join(problems, "; "))
}
}

if cfg.GRPCServerAddr == "" {
Expand Down
27 changes: 27 additions & 0 deletions components/control-plane/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,30 @@ func TestResolveDatabaseProvider(t *testing.T) {
}
}
}

func TestLoadGatewayDatabaseAdminSecretName(t *testing.T) {
t.Setenv("DATABASE_PROVIDER", "cnpg")
for _, tc := range []struct {
name, secret, namespace string
wantErr bool
}{
{"unset", "", "hypershell", false},
{"configured", "gateway-postgres", "hypershell", false},
{"dotted name", "gateway.postgres", "hypershell", false},
{"namespace reference rejected", "other/secret", "hypershell", true},
{"spaces rejected", " gateway-postgres ", "hypershell", true},
{"invalid namespace", "gateway-postgres", "other/namespace", true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("GATEWAY_DATABASE_ADMIN_SECRET_NAME", tc.secret)
t.Setenv("HYPERSHELL_NAMESPACE", tc.namespace)
cfg, err := Load()
if (err != nil) != tc.wantErr {
t.Fatalf("Load() error = %v, want error %v", err, tc.wantErr)
}
if err == nil && cfg.GatewayDatabaseAdminSecretName != tc.secret {
t.Fatalf("secret = %q, want %q", cfg.GatewayDatabaseAdminSecretName, tc.secret)
}
})
}
}
19 changes: 9 additions & 10 deletions components/control-plane/internal/gateway/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,17 @@ type CNPGConfig struct {
ClusterNamespace string
}

// ExternalDBConfig locates the admin credentials for an external
// ManagedDatabase. CredentialsNamespace is the value of
// ManagedDatabase.connection_secret: the NAMESPACE holding the credentials, not
// a Secret name. It must satisfy the hypershell-managed-db- prefix rule, and
// the control plane reads exactly one fixed-name Secret
// (hypershell-managed-db-credentials) inside it.
//
// ManagedDatabaseID is carried for diagnostics only: single-shot cleanup logs
// it so an operator can tie an orphaned role/database back to its registration.
// ExternalDBConfig locates PostgreSQL admin credentials. A configured
// CredentialsSecretName selects a Secret in the controller namespace.
// Otherwise, CredentialsNamespace comes from ManagedDatabase.connection_secret
// and must use the hypershell-managed-db- prefix. That path reads the fixed
// hypershell-managed-db-credentials Secret. ManagedDatabaseID is for diagnostics.
type ExternalDBConfig struct {
CredentialsNamespace string
ManagedDatabaseID string
// CredentialsSecretName selects a controller-configured Secret.
// Empty uses the fixed name and namespace rules for ManagedDatabase.
CredentialsSecretName string
ManagedDatabaseID string
}

// DefaultSandboxImage resolves the base image tenant sandbox pods launch from.
Expand Down
Loading
Loading