diff --git a/CHANGELOG.md b/CHANGELOG.md index bab2694bb..3fd94e394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - (Feature) (ML) Unify API - (Feature) (ML) Add TLS Secrets - (Feature) (ML) Allow to change API port +- (Feature) (ML) Enable TLS ## [1.2.40](https://github.com/arangodb/kube-arangodb/tree/1.2.40) (2024-04-10) - (Feature) Add Core fields to the Scheduler Container Spec diff --git a/pkg/apis/ml/v1alpha1/conditions.go b/pkg/apis/ml/v1alpha1/conditions.go index f8f372616..c7bce44fb 100644 --- a/pkg/apis/ml/v1alpha1/conditions.go +++ b/pkg/apis/ml/v1alpha1/conditions.go @@ -1,7 +1,7 @@ // // DISCLAIMER // -// Copyright 2023 ArangoDB GmbH, Cologne, Germany +// Copyright 2023-2024 ArangoDB GmbH, Cologne, Germany // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ const ( ExtensionMetadataServiceValidCondition api.ConditionType = "MetadataServiceValid" ExtensionServiceAccountReadyCondition api.ConditionType = "ServiceAccountReady" ExtensionStatefulSetReadyCondition api.ConditionType = "ExtensionDeploymentReady" + ExtensionTLSEnabledCondition api.ConditionType = "TLSEnabled" LicenseValidCondition api.ConditionType = "LicenseValid" CronJobSyncedCondition api.ConditionType = "CronJobSynced" BatchJobSyncedCondition api.ConditionType = "BatchJobSynced" diff --git a/pkg/apis/ml/v1alpha1/extension_spec_deployment_tls.go b/pkg/apis/ml/v1alpha1/extension_spec_deployment_tls.go index 242b14c1f..50b9ede3c 100644 --- a/pkg/apis/ml/v1alpha1/extension_spec_deployment_tls.go +++ b/pkg/apis/ml/v1alpha1/extension_spec_deployment_tls.go @@ -27,3 +27,19 @@ type ArangoMLExtensionSpecDeploymentTLS struct { // AltNames define TLS AltNames used when TLS on the ArangoDB is enabled AltNames []string `json:"altNames,omitempty"` } + +func (a *ArangoMLExtensionSpecDeploymentTLS) IsEnabled() bool { + if a == nil || a.Enabled == nil { + return true + } + + return *a.Enabled +} + +func (a *ArangoMLExtensionSpecDeploymentTLS) GetAltNames() []string { + if a == nil || a.AltNames == nil { + return nil + } + + return a.AltNames +} diff --git a/pkg/util/k8sutil/helpers/updator.go b/pkg/util/k8sutil/helpers/updator.go new file mode 100644 index 000000000..0c15b42d7 --- /dev/null +++ b/pkg/util/k8sutil/helpers/updator.go @@ -0,0 +1,244 @@ +// +// DISCLAIMER +// +// Copyright 2024 ArangoDB GmbH, Cologne, Germany +// +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package helpers + +import ( + "context" + + meta "k8s.io/apimachinery/pkg/apis/meta/v1" + + sharedApi "github.com/arangodb/kube-arangodb/pkg/apis/shared/v1" + "github.com/arangodb/kube-arangodb/pkg/logging" + operator "github.com/arangodb/kube-arangodb/pkg/operatorV2" + "github.com/arangodb/kube-arangodb/pkg/util" + "github.com/arangodb/kube-arangodb/pkg/util/errors" + "github.com/arangodb/kube-arangodb/pkg/util/k8sutil/kerrors" +) + +type Action int + +func (a Action) Or(b Action) Action { + if b > a { + return b + } + + return a +} + +const ( + ActionOK Action = iota + ActionReplace + ActionUpdate +) + +type Object interface { + comparable + meta.Object +} + +type Client[T Object] interface { + Get(ctx context.Context, name string, options meta.GetOptions) (T, error) + Update(ctx context.Context, object T, options meta.UpdateOptions) (T, error) + Create(ctx context.Context, object T, options meta.CreateOptions) (T, error) + Delete(ctx context.Context, name string, options meta.DeleteOptions) error +} + +type Generate[T Object] func(ctx context.Context, ref *sharedApi.Object) (T, bool, string, error) + +func OperatorUpdate[T Object](ctx context.Context, logger logging.Logger, client Client[T], ref **sharedApi.Object, generator Generate[T], decisions ...Decision[T]) (bool, error) { + changed, err := Update[T](ctx, logger, client, ref, generator, decisions...) + if err != nil { + return false, err + } + + if changed { + return true, operator.Reconcile("Change in resources") + } + + return false, nil +} + +func Update[T Object](ctx context.Context, logger logging.Logger, client Client[T], ref **sharedApi.Object, generator Generate[T], decisions ...Decision[T]) (bool, error) { + decision := Decision[T](EmptyDecision[T]).With(decisions...) + + if ref == nil { + return false, errors.Errorf("Reference is nil") + } + + currentRef := *ref + + var discoveredObject T + var discoveredObjectExists bool + + if currentRef != nil { + object, err := util.WithKubernetesContextTimeoutP2A2(ctx, client.Get, currentRef.GetName(), meta.GetOptions{}) + if err != nil { + if !kerrors.Is(err, kerrors.NotFound) { + return false, err + } + + *ref = nil + logger. + Str("name", currentRef.GetName()). + Str("checksum", currentRef.GetChecksum()). + Str("uid", string(currentRef.GetUID())). + Debug("Object has been removed") + + return true, nil + } + + if object.GetDeletionTimestamp() != nil { + // Object is currently deleting + logger. + Str("name", currentRef.GetName()). + Str("checksum", currentRef.GetChecksum()). + Str("uid", string(currentRef.GetUID())). + Debug("Object is currently deleting") + return true, nil + } + + if object.GetUID() != currentRef.GetUID() { + logger. + Str("name", currentRef.GetName()). + Str("checksum", currentRef.GetChecksum()). + Str("uid", string(currentRef.GetUID())). + Warn("Recreation Required as UID changed") + + if err := util.WithKubernetesContextTimeoutP1A2(ctx, client.Delete, currentRef.GetName(), meta.DeleteOptions{}); err != nil { + if !kerrors.Is(err, kerrors.NotFound) { + return false, err + } + } + + return true, nil + } + + discoveredObject = object + discoveredObjectExists = true + } + + object, skip, checksum, err := generator(ctx, currentRef.DeepCopy()) + if err != nil { + return false, err + } + + if skip { + // Skip update as it is not required + return false, nil + } + + if object == util.Default[T]() { + // Object is supposed to be removed + if currentRef == nil { + // Nothing to do + return false, nil + } + + // Remove object + if err := util.WithKubernetesContextTimeoutP1A2(ctx, client.Delete, currentRef.GetName(), meta.DeleteOptions{}); err != nil { + if !kerrors.Is(err, kerrors.NotFound) { + return false, err + } + } + + logger. + Str("name", currentRef.GetName()). + Str("checksum", currentRef.GetChecksum()). + Str("uid", string(currentRef.GetUID())). + Info("Object deletion has been requested") + + return true, nil + } + + if !discoveredObjectExists { + // Let's create Object + newObject, err := util.WithKubernetesContextTimeoutP2A2(ctx, client.Create, object, meta.CreateOptions{}) + if err != nil { + return false, err + } + + currentRef = util.NewType(sharedApi.NewObjectWithChecksum(newObject, checksum)) + *ref = currentRef + logger. + Str("name", currentRef.GetName()). + Str("checksum", currentRef.GetChecksum()). + Str("uid", string(currentRef.GetUID())). + Info("Object has been created") + + return true, nil + } + + // Object exists, lets check if update is required + action, err := decision(ctx, DecisionObject[T]{ + Checksum: currentRef.GetChecksum(), + Object: discoveredObject, + }, DecisionObject[T]{ + Checksum: checksum, + Object: object, + }) + if err != nil { + return false, err + } + + switch action { + case ActionOK: + // Nothing to do + return false, nil + case ActionReplace: + // Object needs to be removed + logger. + Str("name", currentRef.GetName()). + Str("checksum", currentRef.GetChecksum()). + Str("uid", string(currentRef.GetUID())). + Info("Object needs to be replaced") + + if err := util.WithKubernetesContextTimeoutP1A2(ctx, client.Delete, currentRef.GetName(), meta.DeleteOptions{}); err != nil { + if !kerrors.Is(err, kerrors.NotFound) { + return false, err + } + } + + return true, nil + case ActionUpdate: + logger. + Str("name", currentRef.GetName()). + Str("checksum", currentRef.GetChecksum()). + Str("uid", string(currentRef.GetUID())). + Info("Object needs to be updated in-place") + + newObject, err := util.WithKubernetesContextTimeoutP2A2(ctx, client.Update, object, meta.UpdateOptions{}) + if err != nil { + if !kerrors.Is(err, kerrors.NotFound) { + return false, err + } + + // Reconcile if object was not found + return true, nil + } + + *ref = util.NewType(sharedApi.NewObjectWithChecksum(newObject, checksum)) + + return true, nil + + default: + return false, errors.Errorf("Unknown action returned") + } +} diff --git a/pkg/util/k8sutil/helpers/updator_decition.go b/pkg/util/k8sutil/helpers/updator_decition.go new file mode 100644 index 000000000..72fd5ee7b --- /dev/null +++ b/pkg/util/k8sutil/helpers/updator_decition.go @@ -0,0 +1,85 @@ +// +// DISCLAIMER +// +// Copyright 2024 ArangoDB GmbH, Cologne, Germany +// +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package helpers + +import ( + "context" + "reflect" +) + +type DecisionObject[T Object] struct { + Checksum string + Object T +} + +func EmptyDecision[T Object](ctx context.Context, current, expected DecisionObject[T]) (Action, error) { + return ActionOK, nil +} + +type Decision[T Object] func(ctx context.Context, current, expected DecisionObject[T]) (Action, error) + +func (d Decision[T]) Call(ctx context.Context, current, expected DecisionObject[T]) (Action, error) { + if d == nil { + return EmptyDecision[T](ctx, current, expected) + } + + return d(ctx, current, expected) +} + +func (d Decision[T]) With(other ...Decision[T]) Decision[T] { + return func(ctx context.Context, current, expected DecisionObject[T]) (Action, error) { + action, err := d.Call(ctx, current, expected) + if err != nil { + return 0, err + } + + for _, o := range other { + if action == ActionReplace { + return ActionReplace, nil + } + + otherAction, err := o.Call(ctx, current, expected) + if err != nil { + return 0, err + } + + action = action.Or(otherAction) + } + + return action, nil + } +} + +func ReplaceChecksum[T Object](ctx context.Context, current, expected DecisionObject[T]) (Action, error) { + if current.Checksum != expected.Checksum { + return ActionReplace, nil + } + + return ActionOK, nil +} + +func UpdateOwnerReference[T Object](ctx context.Context, current, expected DecisionObject[T]) (Action, error) { + if !reflect.DeepEqual(current.Object.GetOwnerReferences(), expected.Object.GetOwnerReferences()) { + return ActionUpdate, nil + } + + return ActionOK, nil +} diff --git a/pkg/util/k8sutil/helpers/updator_test.go b/pkg/util/k8sutil/helpers/updator_test.go new file mode 100644 index 000000000..d1217be7a --- /dev/null +++ b/pkg/util/k8sutil/helpers/updator_test.go @@ -0,0 +1,191 @@ +// +// DISCLAIMER +// +// Copyright 2024 ArangoDB GmbH, Cologne, Germany +// +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package helpers + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + core "k8s.io/api/core/v1" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/uuid" + "k8s.io/client-go/kubernetes/fake" + + sharedApi "github.com/arangodb/kube-arangodb/pkg/apis/shared/v1" + "github.com/arangodb/kube-arangodb/pkg/logging" + "github.com/arangodb/kube-arangodb/pkg/util" + "github.com/arangodb/kube-arangodb/pkg/util/k8sutil/kerrors" + "github.com/arangodb/kube-arangodb/pkg/util/tests" +) + +func init() { + logging.Global().SetRoot(zerolog.New(os.Stdout).With().Timestamp().Logger()) +} + +func runUpdate[T Object](t *testing.T, iterations int, client Client[T], ref **sharedApi.Object, generator Generate[T], decisions ...Decision[T]) { + logger := logging.Global().Get("test") + + i := 0 + + for i = 1; i < 1024; i++ { + var changed bool + t.Run(fmt.Sprintf("Iteration %d", i), func(t *testing.T) { + ok, err := Update[T](context.Background(), logger, client, ref, generator, decisions...) + require.NoError(t, err) + changed = ok + }) + + if !changed { + break + } + } + + require.EqualValues(t, iterations, i, fmt.Sprintf("Expected %d iterations, got %d", iterations, i)) +} + +func get[T Object](t *testing.T, client Client[T], in T) (T, bool) { + obj, err := client.Get(context.Background(), in.GetName(), meta.GetOptions{}) + if err != nil { + if kerrors.Is(err, kerrors.NotFound) { + return util.Default[T](), false + } + + require.NoError(t, err) + } + + return obj, true +} + +func Test_Updator(t *testing.T) { + logging.Global().RegisterLogger("test", logging.Trace) + + client := fake.NewSimpleClientset().CoreV1().Secrets(tests.FakeNamespace) + + var secret = core.Secret{ + ObjectMeta: meta.ObjectMeta{ + Name: "secret", + Namespace: tests.FakeNamespace, + UID: uuid.NewUUID(), + }, + } + var checksum = util.SHA256FromString("") + + var ref *sharedApi.Object + + var retSecret Generate[*core.Secret] = func(_ context.Context, _ *sharedApi.Object) (*core.Secret, bool, string, error) { + return secret.DeepCopy(), false, checksum, nil + } + + t.Run("Ensure default is handled", func(t *testing.T) { + runUpdate[*core.Secret](t, 2, client, &ref, retSecret) + + _, ok := get[*core.Secret](t, client, &secret) + require.True(t, ok) + }) + + t.Run("Ensure rerun is handled", func(t *testing.T) { + runUpdate[*core.Secret](t, 1, client, &ref, retSecret) + + _, ok := get[*core.Secret](t, client, &secret) + require.True(t, ok) + }) + + t.Run("Ensure delete is not handled when skip is requested", func(t *testing.T) { + runUpdate[*core.Secret](t, 1, client, &ref, func(ctx context.Context, _ *sharedApi.Object) (*core.Secret, bool, string, error) { + return nil, true, "", nil + }) + + _, ok := get[*core.Secret](t, client, &secret) + require.True(t, ok) + }) + + t.Run("Ensure delete is handled", func(t *testing.T) { + runUpdate[*core.Secret](t, 3, client, &ref, func(ctx context.Context, _ *sharedApi.Object) (*core.Secret, bool, string, error) { + return nil, false, "", nil + }) + + _, ok := get[*core.Secret](t, client, &secret) + require.False(t, ok) + }) + + t.Run("Recreate", func(t *testing.T) { + runUpdate[*core.Secret](t, 2, client, &ref, retSecret) + + _, ok := get[*core.Secret](t, client, &secret) + require.True(t, ok) + }) + + t.Run("Change checksum without handler", func(t *testing.T) { + checksum = util.SHA256FromString("NEW") + + runUpdate[*core.Secret](t, 1, client, &ref, retSecret) + + require.NotEqual(t, checksum, ref.GetChecksum()) + + _, ok := get[*core.Secret](t, client, &secret) + require.True(t, ok) + }) + + t.Run("Change checksum with recreate handler", func(t *testing.T) { + runUpdate[*core.Secret](t, 4, client, &ref, retSecret, ReplaceChecksum[*core.Secret]) + + require.Equal(t, checksum, ref.GetChecksum()) + + _, ok := get[*core.Secret](t, client, &secret) + require.True(t, ok) + }) + + t.Run("UUID Changed", func(t *testing.T) { + ref.UID = util.NewType(uuid.NewUUID()) + + runUpdate[*core.Secret](t, 4, client, &ref, retSecret) + + s, ok := get[*core.Secret](t, client, &secret) + require.True(t, ok) + require.Equal(t, ref.GetUID(), s.GetUID()) + }) + + t.Run("Owner Added Without Handler", func(t *testing.T) { + secret.SetOwnerReferences([]meta.OwnerReference{ + { + UID: uuid.NewUUID(), + }, + }) + + runUpdate[*core.Secret](t, 1, client, &ref, retSecret) + + s, ok := get[*core.Secret](t, client, &secret) + require.True(t, ok) + require.NotEqual(t, secret.GetOwnerReferences(), s.GetOwnerReferences()) + }) + + t.Run("Owner Added With Handler", func(t *testing.T) { + runUpdate[*core.Secret](t, 2, client, &ref, retSecret, UpdateOwnerReference[*core.Secret]) + + s, ok := get[*core.Secret](t, client, &secret) + require.True(t, ok) + require.Equal(t, secret.GetOwnerReferences(), s.GetOwnerReferences()) + }) +} diff --git a/pkg/util/k8sutil/secrets.go b/pkg/util/k8sutil/secrets.go index a17a5fa74..4ae62fa73 100644 --- a/pkg/util/k8sutil/secrets.go +++ b/pkg/util/k8sutil/secrets.go @@ -213,10 +213,9 @@ func GetTLSKeyfileFromSecret(s *core.Secret) (string, error) { return string(keyfile), nil } -// CreateTLSKeyfileSecret creates a secret used to store a PEM encoded keyfile +// RenderTLSKeyfileSecret renders a secret used to store a PEM encoded keyfile // in the format ArangoDB accepts it for its `--ssl.keyfile` option. -func CreateTLSKeyfileSecret(ctx context.Context, secrets secretv1.ModInterface, secretName string, keyfile string, - ownerRef *meta.OwnerReference) (*core.Secret, error) { +func RenderTLSKeyfileSecret(secretName string, keyfile string, ownerRef *meta.OwnerReference) *core.Secret { // Create secret secret := &core.Secret{ ObjectMeta: meta.ObjectMeta{ @@ -228,6 +227,14 @@ func CreateTLSKeyfileSecret(ctx context.Context, secrets secretv1.ModInterface, } // Attach secret to owner AddOwnerRefToObject(secret, ownerRef) + return secret +} + +// CreateTLSKeyfileSecret creates a secret used to store a PEM encoded keyfile +// in the format ArangoDB accepts it for its `--ssl.keyfile` option. +func CreateTLSKeyfileSecret(ctx context.Context, secrets secretv1.ModInterface, secretName string, keyfile string, + ownerRef *meta.OwnerReference) (*core.Secret, error) { + secret := RenderTLSKeyfileSecret(secretName, keyfile, ownerRef) if s, err := secrets.Create(ctx, secret, meta.CreateOptions{}); err != nil { // Failed to create secret return nil, kerrors.NewResourceError(err, secret)