Skip to content
Open
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
17 changes: 15 additions & 2 deletions cloud/sdk_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ package cloud

import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
Expand All @@ -32,6 +33,18 @@ const (
)

func TestSDKClientCreateServerUsesExpectedPayload(t *testing.T) {
for name, userData := range map[string][]byte{
"cloud-init": []byte("#cloud-config\n"),
"ignition": []byte("{\n \"ignition\": {\"version\": \"3.2.0\"}\n}\n"),
} {
t.Run(name, func(t *testing.T) {
testSDKClientCreateServerPayload(t, userData)
})
}
}

func testSDKClientCreateServerPayload(t *testing.T, userData []byte) {
t.Helper()
var createPayload map[string]any
server := newSDKTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
Expand Down Expand Up @@ -71,7 +84,7 @@ func TestSDKClientCreateServerUsesExpectedPayload(t *testing.T) {
SSHKeyName: "default",
NetworkID: testSDKNetworkID,
SecurityGroups: []string{testSDKSecurityGroup},
UserData: []byte("#cloud-config\n"),
UserData: userData,
RootVolume: RootVolumeInput{
SizeGiB: 50,
PerformanceClass: "storage_premium_perf6",
Expand All @@ -94,7 +107,7 @@ func TestSDKClientCreateServerUsesExpectedPayload(t *testing.T) {
assertFieldAbsent(t, createPayload, "imageId")
assertStringField(t, createPayload, "availabilityZone", "eu01-1")
assertStringField(t, createPayload, "keypairName", "default")
assertStringField(t, createPayload, "userData", "I2Nsb3VkLWNvbmZpZwo=")
assertStringField(t, createPayload, "userData", base64.StdEncoding.EncodeToString(userData))
assertBoolField(t, createPayload, "configDrive", true)
assertNestedStringField(t, createPayload, []string{"labels", "cluster"}, "test")
assertNestedStringField(t, createPayload, []string{"networking", "networkId"}, testSDKNetworkID)
Expand Down
41 changes: 39 additions & 2 deletions cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,21 @@ package main
import (
"crypto/tls"
"flag"
"fmt"
"os"
"strings"

// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
_ "k8s.io/client-go/plugin/pkg/client/auth"

"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/validation"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
Expand Down Expand Up @@ -62,6 +66,7 @@ func main() {
var metricsCertPath, metricsCertName, metricsCertKey string
var webhookCertPath, webhookCertName, webhookCertKey string
var enableLeaderElection bool
var namespace, leaderElectionNamespace string
var probeAddr string
var secureMetrics bool
var enableHTTP2 bool
Expand All @@ -72,6 +77,9 @@ func main() {
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"Enable leader election for controller manager. "+
"Enabling this will ensure there is only one active controller manager.")
flag.StringVar(&namespace, "namespace", "", "Namespace to watch. Leave empty to watch all namespaces.")
flag.StringVar(&leaderElectionNamespace, "leader-election-namespace", "",
"Namespace for leader election leases. Defaults to --namespace when set, otherwise the pod namespace.")
flag.BoolVar(&secureMetrics, "metrics-secure", true,
"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.")
Expand Down Expand Up @@ -158,7 +166,7 @@ func main() {
metricsServerOptions.KeyName = metricsCertKey
}

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
managerOptions := ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
WebhookServer: webhookServer,
Expand All @@ -176,7 +184,12 @@ func main() {
// if you are doing or is intended to do any operation such as perform cleanups
// after the manager stops then its usage might be unsafe.
// LeaderElectionReleaseOnCancel: true,
})
}
if err := configureNamespaces(&managerOptions, namespace, leaderElectionNamespace); err != nil {
setupLog.Error(err, "Invalid namespace configuration")
os.Exit(1)
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), managerOptions)
if err != nil {
setupLog.Error(err, "Failed to start manager")
os.Exit(1)
Expand Down Expand Up @@ -235,3 +248,27 @@ func main() {
os.Exit(1)
}
}

// configureNamespaces keeps the cached reads and watches in the requested
// namespace. The default lease namespace follows the watch namespace so that
// multiple namespaced managers can each elect a leader using namespaced RBAC.
func configureNamespaces(options *ctrl.Options, namespace, leaderElectionNamespace string) error {
for name, value := range map[string]string{
"namespace": namespace, "leader-election-namespace": leaderElectionNamespace,
} {
if value == "" {
continue
}
if problems := validation.IsDNS1123Label(value); len(problems) > 0 {
return fmt.Errorf("--%s must be a valid namespace: %s", name, strings.Join(problems, "; "))
}
}
if namespace != "" {
options.Cache.DefaultNamespaces = map[string]cache.Config{namespace: {}}
if leaderElectionNamespace == "" {
leaderElectionNamespace = namespace
}
}
options.LeaderElectionNamespace = leaderElectionNamespace
return nil
}
140 changes: 140 additions & 0 deletions cmd/manager/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
Copyright 2026.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0
*/

package main

import (
"context"
"strings"
"testing"
"time"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)

func TestConfigureNamespaces(t *testing.T) {
for _, tt := range []struct {
name string
namespace string
leaderElectionNamespace string
wantLeaseNamespace string
wantError string
}{
{name: "cluster wide defaults"},
{name: "namespaced lease defaults", namespace: "clusters-example", wantLeaseNamespace: "clusters-example"},
{
name: "explicit lease namespace", namespace: "clusters-example",
leaderElectionNamespace: "provider-system", wantLeaseNamespace: "provider-system",
},
{
name: "cluster wide with explicit lease namespace",
leaderElectionNamespace: "provider-system", wantLeaseNamespace: "provider-system",
},
{name: "reject multiple namespaces", namespace: "one,two", wantError: "--namespace"},
{name: "reject whitespace", namespace: " ", wantError: "--namespace"},
{name: "reject uppercase", namespace: "HostedCluster", wantError: "--namespace"},
{name: "reject invalid lease namespace", leaderElectionNamespace: "one/two", wantError: "--leader-election-namespace"},
} {
t.Run(tt.name, func(t *testing.T) {
options := ctrl.Options{}
err := configureNamespaces(&options, tt.namespace, tt.leaderElectionNamespace)
if tt.wantError != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("configureNamespaces() error = %v, want %q", err, tt.wantError)
}
return
}
if err != nil {
t.Fatalf("configureNamespaces() error = %v", err)
}
if options.LeaderElectionNamespace != tt.wantLeaseNamespace {
t.Fatalf("lease namespace = %q, want %q", options.LeaderElectionNamespace, tt.wantLeaseNamespace)
}
if tt.namespace == "" {
if options.Cache.DefaultNamespaces != nil {
t.Fatal("cluster-wide manager unexpectedly restricts namespaces")
}
} else if _, ok := options.Cache.DefaultNamespaces[tt.namespace]; !ok || len(options.Cache.DefaultNamespaces) != 1 {
t.Fatalf("cache namespaces = %v, want only %q", options.Cache.DefaultNamespaces, tt.namespace)
}
})
}
}

// Exercise the actual controller-runtime cache: a namespaced deployment must
// neither observe nor read another hosted cluster's bootstrap or cloud secrets.
func TestNamespaceCacheIsolation(t *testing.T) {
testEnv := &envtest.Environment{}
config, err := testEnv.Start()
if err != nil {
t.Fatalf("start envtest: %v", err)
}
t.Cleanup(func() {
if err := testEnv.Stop(); err != nil {
t.Errorf("stop envtest: %v", err)
}
})
liveClient, err := client.New(config, client.Options{Scheme: scheme})
if err != nil {
t.Fatalf("create live client: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
for _, namespace := range []string{"hosted-one", "hosted-two"} {
if err := liveClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}); err != nil {
t.Fatalf("create namespace: %v", err)
}
if err := liveClient.Create(ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "credentials"},
Data: map[string][]byte{"value": []byte(namespace)},
}); err != nil {
t.Fatalf("create secret: %v", err)
}
}
options := ctrl.Options{}
if err := configureNamespaces(&options, "hosted-one", ""); err != nil {
t.Fatal(err)
}
options.Cache.Scheme = scheme
scopedCache, err := cache.New(config, options.Cache)
if err != nil {
t.Fatalf("create namespace cache: %v", err)
}
cacheErrors := make(chan error, 1)
go func() { cacheErrors <- scopedCache.Start(ctx) }()
t.Cleanup(func() {
cancel()
if err := <-cacheErrors; err != nil {
t.Errorf("run cache: %v", err)
}
})
secret := &corev1.Secret{}
if err := scopedCache.Get(ctx, client.ObjectKey{Namespace: "hosted-one", Name: "credentials"}, secret); err != nil {
t.Fatalf("get watched secret: %v", err)
}
if string(secret.Data["value"]) != "hosted-one" {
t.Fatalf("read wrong namespace's secret: %s", secret.Namespace)
}
if err := scopedCache.Get(ctx, client.ObjectKey{Namespace: "hosted-two", Name: "credentials"}, &corev1.Secret{}); err == nil {
t.Fatal("cache permitted a secret read outside its namespace")
}
secrets := &corev1.SecretList{}
if err := scopedCache.List(ctx, secrets); err != nil {
t.Fatalf("list watched secrets: %v", err)
}
if len(secrets.Items) != 1 || secrets.Items[0].Namespace != "hosted-one" {
t.Fatalf("cache list escaped namespace scope: %v", secrets.Items)
}
}
6 changes: 3 additions & 3 deletions controller/controller_test_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ const (
testImageID = "33333333-3333-3333-3333-333333333333"
)

func createCredentialsSecret(ctx context.Context, name, namespace, projectID string) {
func createCredentialsSecret(ctx context.Context, name string) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Data: map[string][]byte{
"project-id": []byte(projectID),
"project-id": []byte(testProjectID),
"serviceaccount.json": []byte("{}"),
},
}
Expand Down
7 changes: 7 additions & 0 deletions controller/stackitcluster_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"k8s.io/client-go/tools/events"
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
clusterutil "sigs.k8s.io/cluster-api/util"
"sigs.k8s.io/cluster-api/util/annotations"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
Expand Down Expand Up @@ -69,6 +70,12 @@ func (r *StackitClusterReconciler) Reconcile(ctx context.Context, req ctrl.Reque
}
return ctrl.Result{}, err
}
// An external controller owns this infrastructure object's lifecycle and
// status. Do not add finalizers, patch conditions, or access cloud resources.
if annotations.IsExternallyManaged(stackitCluster) {
log.V(1).Info("Skipping externally managed StackitCluster")
return ctrl.Result{}, nil
}

cluster, err := clusterutil.GetOwnerCluster(ctx, r.Client, stackitCluster.ObjectMeta)
if err != nil {
Expand Down
Loading
Loading