diff --git a/operator/Makefile b/operator/Makefile index b22e803ca..269dd9c32 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -218,7 +218,6 @@ helm-uninstall-dependencies: @if $(KUBECTL) version --client=false > /dev/null 2>&1; then \ $(HELM) uninstall cert-manager --namespace cert-manager > /dev/null 2>&1 || true; \ $(KUBECTL) delete namespace cert-manager > /dev/null 2>&1 || true; \ - $(KUBECTL) delete -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.1/cert-manager.crds.yaml > /dev/null 2>&1 || true; \ else \ echo "No Kubernetes cluster detected. Skipping dependencies uninstallation."; \ fi diff --git a/operator/api/v1alpha1/paladin_types.go b/operator/api/v1alpha1/paladin_types.go index 8236dff81..28cdb1766 100644 --- a/operator/api/v1alpha1/paladin_types.go +++ b/operator/api/v1alpha1/paladin_types.go @@ -37,6 +37,10 @@ type PaladinSpec struct { // (vs. configuring a connection to a production blockchain network) BesuNode string `json:"besuNode,omitempty"` + // AuthConfig is used to provide authentication details for blockchain connections + // If this is set, it will override the auth details in the config + AuthConfig *AuthConfig `json:"authConfig,omitempty"` + // Optionally tune the service definition. // We merge any configuration you add (such as node ports) for the following services: // "rpc-http" - 8545 (TCP), @@ -139,6 +143,24 @@ type SecretBackedSigner struct { KeySelector string `json:"keySelector"` } +type AuthMethod string + +const AuthMethodSecret AuthMethod = "secret" + +type AuthConfig struct { + // auth method to use for the connection + // +kubebuilder:validation:Enum=secret + AuthMethod AuthMethod `json:"authMethod"` + + // SecretAuth is used to provide the name of the secret to use for authentication + AuthSecret *AuthSecret `json:"authSecret,omitempty"` +} + +type AuthSecret struct { + // The name of the secret to use for authentication + Name string `json:"name"` +} + // StatusReason is an enumeration of possible failure causes. Each StatusReason // must map to a single HTTP status code, but multiple reasons may map // to the same HTTP status code. diff --git a/operator/api/v1alpha1/zz_generated.deepcopy.go b/operator/api/v1alpha1/zz_generated.deepcopy.go index 5cfec3603..4d8fbb2e6 100644 --- a/operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -26,6 +26,41 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthConfig) DeepCopyInto(out *AuthConfig) { + *out = *in + if in.AuthSecret != nil { + in, out := &in.AuthSecret, &out.AuthSecret + *out = new(AuthSecret) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthConfig. +func (in *AuthConfig) DeepCopy() *AuthConfig { + if in == nil { + return nil + } + out := new(AuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthSecret) DeepCopyInto(out *AuthSecret) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthSecret. +func (in *AuthSecret) DeepCopy() *AuthSecret { + if in == nil { + return nil + } + out := new(AuthSecret) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Besu) DeepCopyInto(out *Besu) { *out = *in @@ -633,6 +668,11 @@ func (in *PaladinSpec) DeepCopyInto(out *PaladinSpec) { *out = make([]SecretBackedSigner, len(*in)) copy(*out, *in) } + if in.AuthConfig != nil { + in, out := &in.AuthConfig, &out.AuthConfig + *out = new(AuthConfig) + (*in).DeepCopyInto(*out) + } in.Service.DeepCopyInto(&out.Service) if in.Domains != nil { in, out := &in.Domains, &out.Domains diff --git a/operator/build.gradle b/operator/build.gradle index 89f452c55..83ad813ae 100644 --- a/operator/build.gradle +++ b/operator/build.gradle @@ -70,17 +70,30 @@ def printClusterStatus(String namespace) { println getAllProcess.in.text.trim() } -def verifyResourceCreated(String namespace, String resourceType, String resourceName, int timeoutSeconds = 60) { - - // Wait for resource creation - def existsCommand = ["kubectl", "wait", "--for=create", "${resourceType}", "${resourceName}", "-n", namespace, "--timeout=${timeoutSeconds}s"] - def existsProcess = existsCommand.execute() - existsProcess.waitFor() - println "Creation Output: ${existsProcess.text}" - - if (existsProcess.exitValue() != 0) { +def verifyResourceCreated(String namespace, String resourceType, String resourceName, String desiredReplicas = "1", int timeoutSeconds = 60) { + def startTime = System.currentTimeMillis() + def endTime = startTime + (timeoutSeconds * 1000) + def resourceReady = false + + while (System.currentTimeMillis() < endTime) { + def command = ["kubectl", "get", resourceType, resourceName, "-n", namespace, "-o", "jsonpath={.status.readyReplicas}"] + def process = command.execute() + process.waitFor() + def readyReplicas = process.text.trim() + println "Current Ready Replicas: ${readyReplicas}" + + if (process.exitValue() == 0 && readyReplicas == desiredReplicas) { + resourceReady = true + break + } + Thread.sleep(2000) // Wait for 2 seconds before retrying + } + + if (!resourceReady) { printClusterStatus(namespace) - throw new Exception("Resource ${resourceType}/${resourceName} (${namespace}) was not created within the expected time.") + throw new Exception("${resourceType} ${resourceName} in namespace ${namespace} did not become ready within ${timeoutSeconds} seconds.") + } else { + println "${resourceType} ${resourceName} in namespace ${namespace} is ready with ${desiredReplicas} replicas." } } @@ -219,39 +232,34 @@ task installOperator(type: Exec, dependsOn: [installCrds, promoteKindImages, pre task verifyOperator(dependsOn: installOperator) { doLast { println 'Waiting for operator deployment to become ready...' - verifyResourceReady(namespace, 'deployment', 'paladin-operator', 'condition=available') + verifyResourceCreated(namespace, 'deployment', 'paladin-operator', "1") } } -// // Task to create the nodes -// task createNode(type: Exec, dependsOn: verifyOperator) { -// executable 'make' -// args 'create-node' -// args "NAMESPACE=${namespace}" -// } - -// // Task to verify the besu statefulSet -// task verifyBesu(dependsOn: createNode) { -// doLast { -// println 'Waiting for besu statefulSet to become ready...' - -// verifyResourceCreated(namespace, 'statefulset', 'besu-node1', 120) -// verifyResourceReady(namespace, 'statefulset', 'besu-node1', 'jsonpath=.status.readyReplicas=1', 60) -// } -// } - -// // Task to verify the paladin statefulSet -// task verifyPaladin(dependsOn: createNode) { -// doLast { -// println 'Waiting for paladin statefulSet to become ready...' - -// verifyResourceCreated(namespace, 'statefulset', 'paladin-node1', 120) -// verifyResourceReady(namespace, 'statefulset', 'paladin-node1', 'jsonpath=.status.readyReplicas=1', 60) -// } -// } +// Task to verify the besu statefulSet +task verifyBesu(dependsOn: verifyOperator) { + doLast { + println 'Waiting for besu statefulSet to become ready...' + + verifyResourceCreated(namespace, 'statefulset', 'besu-node1', "1", 120) + verifyResourceCreated(namespace, 'statefulset', 'besu-node2', "1", 120) + verifyResourceCreated(namespace, 'statefulset', 'besu-node3', "1", 120) + } +} + +// Task to verify the paladin statefulSet +task verifyPaladin(dependsOn: verifyBesu) { + doLast { + println 'Waiting for paladin statefulSet to become ready...' + + verifyResourceCreated(namespace, 'statefulset', 'paladin-node1', "1", 120) + verifyResourceCreated(namespace, 'statefulset', 'paladin-node2', "1", 120) + verifyResourceCreated(namespace, 'statefulset', 'paladin-node3', "1", 120) + } +} // The 'deplay' runs the whole flow -task deploy(dependsOn: [copySolidity, copyZetoSolidity, verifyOperator]) { +task deploy(dependsOn: [copySolidity, copyZetoSolidity, verifyPaladin]) { doLast { println 'Deplopy setup completed. Operator is running in the paladin namespace.' } diff --git a/operator/charts/paladin-operator-crd/Chart.yaml b/operator/charts/paladin-operator-crd/Chart.yaml index 5e4fd0213..00b5cb88c 100644 --- a/operator/charts/paladin-operator-crd/Chart.yaml +++ b/operator/charts/paladin-operator-crd/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.0.0 +version: 0.0.1 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.0.0" +appVersion: "0.0.1" diff --git a/operator/charts/paladin-operator/Chart.yaml b/operator/charts/paladin-operator/Chart.yaml index 827cc9f58..3c654fcfe 100644 --- a/operator/charts/paladin-operator/Chart.yaml +++ b/operator/charts/paladin-operator/Chart.yaml @@ -25,7 +25,7 @@ appVersion: "0.0.2" dependencies: - name: paladin-operator-crd - version: 0.0.0 + version: 0.0.1 repository: "file://../paladin-operator-crd/" condition: installCRDs diff --git a/operator/charts/paladin-operator/templates/operator/clusterrole.yaml b/operator/charts/paladin-operator/templates/operator/role.yaml similarity index 100% rename from operator/charts/paladin-operator/templates/operator/clusterrole.yaml rename to operator/charts/paladin-operator/templates/operator/role.yaml diff --git a/operator/charts/paladin-operator/templates/operator/cluserrolebinding.yaml b/operator/charts/paladin-operator/templates/operator/rolebinding.yaml similarity index 100% rename from operator/charts/paladin-operator/templates/operator/cluserrolebinding.yaml rename to operator/charts/paladin-operator/templates/operator/rolebinding.yaml diff --git a/operator/config/crd/bases/core.paladin.io_paladins.yaml b/operator/config/crd/bases/core.paladin.io_paladins.yaml index 40be9dd5e..7f5ac99ed 100644 --- a/operator/config/crd/bases/core.paladin.io_paladins.yaml +++ b/operator/config/crd/bases/core.paladin.io_paladins.yaml @@ -45,6 +45,29 @@ spec: spec: description: PaladinSpec defines the desired state of Paladin properties: + authConfig: + description: |- + AuthConfig is used to provide authentication details for blockchain connections + If this is set, it will override the auth details in the config + properties: + authMethod: + description: auth method to use for the connection + enum: + - secret + type: string + authSecret: + description: SecretAuth is used to provide the name of the secret + to use for authentication + properties: + name: + description: The name of the secret to use for authentication + type: string + required: + - name + type: object + required: + - authMethod + type: object besuNode: description: |- Optionally bind to a local besu node deployed with this operator diff --git a/operator/internal/controller/common.go b/operator/internal/controller/common.go index 63f12ef8a..8239a443d 100644 --- a/operator/internal/controller/common.go +++ b/operator/internal/controller/common.go @@ -18,6 +18,8 @@ package controller import ( "context" + "fmt" + "reflect" "sort" corev1alpha1 "github.com/kaleido-io/paladin/operator/api/v1alpha1" @@ -165,3 +167,45 @@ func setCondition( // Update or append the condition meta.SetStatusCondition(conditions, condition) } + +func mapToStruct(data map[string][]byte, result interface{}) error { + // Ensure that result is a pointer to a struct + v := reflect.ValueOf(result) + if v.Kind() != reflect.Ptr || v.IsNil() { + return fmt.Errorf("result argument must be a non-nil pointer to a struct") + } + v = v.Elem() + if v.Kind() != reflect.Struct { + return fmt.Errorf("result argument must be a pointer to a struct") + } + + t := v.Type() + + for i := 0; i < v.NumField(); i++ { + fieldValue := v.Field(i) + fieldType := t.Field(i) + + // Skip unexported fields + if !fieldValue.CanSet() { + continue + } + + // Get the JSON tag or use the field name + tag := fieldType.Tag.Get("json") + if tag == "" { + tag = fieldType.Name + } + + // Check if the map contains the key + if val, exists := data[tag]; exists { + switch fieldValue.Kind() { + case reflect.String: + fieldValue.SetString(string(val)) + default: + return fmt.Errorf("unsupported field type: %s", fieldValue.Kind()) + } + } + } + + return nil +} diff --git a/operator/internal/controller/common_test.go b/operator/internal/controller/common_test.go new file mode 100644 index 000000000..060bb1d82 --- /dev/null +++ b/operator/internal/controller/common_test.go @@ -0,0 +1,87 @@ +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMapToStruct(t *testing.T) { + + type example struct { + Username string `json:"username"` + Password string `json:"password"` + } + tests := []struct { + name string + data map[string][]byte + result interface{} + want interface{} + wantErr bool + }{ + { + name: "Valid mapping to example", + data: map[string][]byte{ + "username": []byte("testuser"), + "password": []byte("testpass"), + }, + result: &example{}, + want: &example{ + Username: "testuser", + Password: "testpass", + }, + wantErr: false, + }, + { + name: "Missing key in map", + data: map[string][]byte{ + "username": []byte("testuser"), + }, + result: &example{}, + want: &example{ + Username: "testuser", + Password: "", + }, + wantErr: false, + }, + { + name: "Result is not a pointer", + data: map[string][]byte{}, + result: example{}, + want: nil, + wantErr: true, + }, + { + name: "Result is nil pointer", + data: map[string][]byte{}, + result: (*example)(nil), + want: nil, + wantErr: true, + }, + { + name: "Unsupported field type", + data: map[string][]byte{"unsupported": []byte("value")}, + result: &struct { + UnsupportedField int `json:"unsupported"` + }{}, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := mapToStruct(tt.data, tt.result) + + if tt.wantErr { + assert.Error(t, err, "Expected an error but got none") + } else { + assert.NoError(t, err, "Did not expect an error but got one") + } + + if !tt.wantErr { + assert.Equal(t, tt.want, tt.result, "Result mismatch") + } + }) + } +} diff --git a/operator/internal/controller/paladin_controller.go b/operator/internal/controller/paladin_controller.go index 8ba528c1d..9cacc2eb4 100644 --- a/operator/internal/controller/paladin_controller.go +++ b/operator/internal/controller/paladin_controller.go @@ -697,6 +697,11 @@ func (r *PaladinReconciler) generatePaladinConfig(ctx context.Context, node *cor }) } + // Override the default config with the user provided config + if err := r.generatePaladinAuthConfig(ctx, node, &pldConf); err != nil { + return "", nil, err + } + // DB needs merging from user config and our config if err := r.generatePaladinDBConfig(ctx, node, &pldConf, name); err != nil { return "", nil, err @@ -731,6 +736,34 @@ func (r *PaladinReconciler) generatePaladinConfig(ctx context.Context, node *cor return string(b), tlsSecrets, err } +func (r *PaladinReconciler) generatePaladinAuthConfig(ctx context.Context, node *corev1alpha1.Paladin, pldConf *pldconf.PaladinConfig) error { + // generate the Paladin auth config + if node.Spec.AuthConfig == nil { + return nil + } + + switch node.Spec.AuthConfig.AuthMethod { + case corev1alpha1.AuthMethodSecret: + if node.Spec.AuthConfig.AuthSecret == nil { + return fmt.Errorf("AuthSecret must be provided when using AuthMethodSecret") + } + secretName := node.Spec.AuthConfig.AuthSecret.Name + if secretName == "" { + return fmt.Errorf("AuthSecret must be provided when using AuthMethodSecret") + } + sec := &corev1.Secret{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: secretName, Namespace: node.Namespace}, sec); err != nil { + return err + } + if sec.Data == nil { + return fmt.Errorf("Secret %s has no data", secretName) + } + mapToStruct(sec.Data, &pldConf.Blockchain.HTTP.Auth) + mapToStruct(sec.Data, &pldConf.Blockchain.WS.Auth) + } + return nil +} + func (r *PaladinReconciler) generatePaladinDBConfig(ctx context.Context, node *corev1alpha1.Paladin, pldConf *pldconf.PaladinConfig, name string) error { dbSpec := &node.Spec.Database diff --git a/operator/internal/controller/paladin_controller_test.go b/operator/internal/controller/paladin_controller_test.go index 293f07787..cf51e1b01 100644 --- a/operator/internal/controller/paladin_controller_test.go +++ b/operator/internal/controller/paladin_controller_test.go @@ -23,11 +23,15 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/kaleido-io/paladin/config/pkg/pldconf" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" corev1alpha1 "github.com/kaleido-io/paladin/operator/api/v1alpha1" @@ -193,3 +197,158 @@ func TestPaladin_GetLabels(t *testing.T) { assert.Equal(t, expectedLabels, labels, "labels should match expected labels") } + +// package controllers + +// import ( +// "context" +// "fmt" +// "testing" + +// "github.com/stretchr/testify/assert" +// corev1 "k8s.io/api/core/v1" +// "k8s.io/apimachinery/pkg/types" +// "sigs.k8s.io/controller-runtime/pkg/client/fake" + +// corev1alpha1 "path/to/your/api/v1alpha1" +// "path/to/your/pldconf" +// ) + +func TestGeneratePaladinAuthConfig(t *testing.T) { + tests := []struct { + name string + node *corev1alpha1.Paladin + secret *corev1.Secret + wantErr bool + expected *pldconf.PaladinConfig + }{ + { + name: "Valid AuthConfig with secret", + node: &corev1alpha1.Paladin{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Namespace: "default", + }, + Spec: corev1alpha1.PaladinSpec{ + AuthConfig: &corev1alpha1.AuthConfig{ + AuthMethod: corev1alpha1.AuthMethodSecret, + AuthSecret: &corev1alpha1.AuthSecret{Name: "test-secret"}, + }, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "username": []byte("testuser"), + "password": []byte("testpass"), + }, + }, + wantErr: false, + expected: &pldconf.PaladinConfig{ + Blockchain: pldconf.EthClientConfig{ + HTTP: pldconf.HTTPClientConfig{ + Auth: pldconf.HTTPBasicAuthConfig{ + Username: "testuser", + Password: "testpass", + }, + }, + WS: pldconf.WSClientConfig{ + HTTPClientConfig: pldconf.HTTPClientConfig{ + Auth: pldconf.HTTPBasicAuthConfig{ + Username: "testuser", + Password: "testpass", + }, + }, + }, + }, + }, + }, + { + name: "Secret not found", + node: &corev1alpha1.Paladin{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Namespace: "default", + }, + Spec: corev1alpha1.PaladinSpec{ + AuthConfig: &corev1alpha1.AuthConfig{ + AuthMethod: corev1alpha1.AuthMethodSecret, + AuthSecret: &corev1alpha1.AuthSecret{Name: "test-secret"}, + }, + }, + }, + secret: nil, + wantErr: true, + }, + { + name: "Missing AuthSecret", + node: &corev1alpha1.Paladin{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + Namespace: "default", + }, + Spec: corev1alpha1.PaladinSpec{ + AuthConfig: &corev1alpha1.AuthConfig{ + AuthMethod: corev1alpha1.AuthMethodSecret, + }, + }, + }, + secret: nil, + wantErr: true, + }, + { + name: "Secret with no data", + node: &corev1alpha1.Paladin{ + Spec: corev1alpha1.PaladinSpec{ + AuthConfig: &corev1alpha1.AuthConfig{ + AuthMethod: corev1alpha1.AuthMethodSecret, + AuthSecret: &corev1alpha1.AuthSecret{Name: "empty-secret"}, + }, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "empty-secret", + Namespace: "default", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a fake client and populate it with the secret if provided + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = corev1alpha1.AddToScheme(scheme) + ctx := context.TODO() + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + if tt.secret != nil { + err := client.Create(ctx, tt.secret) + require.NoError(t, err) + } + + reconciler := &PaladinReconciler{ + Client: client, + } + + // Call the method under test + pldConf := &pldconf.PaladinConfig{} + err := reconciler.generatePaladinAuthConfig(ctx, tt.node, pldConf) + + // Verify the results + if tt.wantErr { + assert.Error(t, err, "Expected an error but got none") + } else { + require.NoError(t, err, "Did not expect an error but got one") + assert.Equal(t, tt.expected.Blockchain.HTTP.Auth, pldConf.Blockchain.HTTP.Auth, "HTTP Auth mismatch") + assert.Equal(t, tt.expected.Blockchain.WS.Auth, pldConf.Blockchain.WS.Auth, "WS Auth mismatch") + } + }) + } +}