diff --git a/internal/controllers/reconciliation/controller.go b/internal/controllers/reconciliation/controller.go index f8d5bc8c..b01ae20c 100644 --- a/internal/controllers/reconciliation/controller.go +++ b/internal/controllers/reconciliation/controller.go @@ -57,7 +57,7 @@ func New(mgr *reconstitution.Manager, upstream *rest.Config) error { }) } -func (c *Controller) Name() string { return "syncController" } +func (c *Controller) Name() string { return "reconciliationController" } func (c *Controller) Reconcile(ctx context.Context, req *reconstitution.Request) (ctrl.Result, error) { logger := logr.FromContextOrDiscard(ctx) @@ -176,6 +176,7 @@ func (c *Controller) buildPatch(ctx context.Context, prev, resource *reconstitut model := c.openapi.LookupResource(resource.Object.GroupVersionKind()) if model == nil { + // TODO: Remove? // Fall back to non-strategic merge logr.FromContextOrDiscard(ctx).Info("falling back to non-strategic merge patch because resource was not found in openapi spec") return jsonmergepatch.CreateThreeWayJSONMergePatch(prevManifest, []byte(resource.Manifest), desiredJS) diff --git a/internal/controllers/reconciliation/discoverycache.go b/internal/controllers/reconciliation/discoverycache.go new file mode 100644 index 00000000..bcb8fa29 --- /dev/null +++ b/internal/controllers/reconciliation/discoverycache.go @@ -0,0 +1,18 @@ +package reconciliation + +import ( + "sync" + + "k8s.io/kubectl/pkg/util/openapi" +) + +// TODO + +type discoveryCache struct { + mut sync.Mutex + current openapi.Resources +} + +func (d *discoveryCache) Get() openapi.Resources { + return nil +} diff --git a/internal/controllers/reconciliation/fixtures/v1/config/crd/enotest.azure.io_testresources.yaml b/internal/controllers/reconciliation/fixtures/v1/config/crd/enotest.azure.io_testresources.yaml new file mode 100644 index 00000000..228b7c60 --- /dev/null +++ b/internal/controllers/reconciliation/fixtures/v1/config/crd/enotest.azure.io_testresources.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.13.0 + name: testresources.enotest.azure.io +spec: + group: enotest.azure.io + names: + kind: TestResource + listKind: TestResourceList + plural: testresources + singular: testresource + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + values: + items: + properties: + int: + type: integer + type: object + type: array + type: object + status: + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/controllers/reconciliation/fixtures/v1/types.go b/internal/controllers/reconciliation/fixtures/v1/types.go new file mode 100644 index 00000000..f1362289 --- /dev/null +++ b/internal/controllers/reconciliation/fixtures/v1/types.go @@ -0,0 +1,48 @@ +// +kubebuilder:object:generate=true +// +groupName=enotest.azure.io +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +//go:generate controller-gen object crd rbac:roleName=resourceprovider paths=./... + +var ( + SchemeGroupVersion = schema.GroupVersion{Group: "enotest.azure.io", Version: "v1"} + SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} +) + +func init() { + SchemeBuilder.Register(&TestResourceList{}, &TestResource{}) +} + +// +kubebuilder:object:root=true +type TestResourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []TestResource `json:"items"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +type TestResource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec TestResourceSpec `json:"spec,omitempty"` + Status TestResourceStatus `json:"status,omitempty"` +} + +type TestResourceSpec struct { + Values []*TestValue `json:"values,omitempty"` +} + +type TestValue struct { + Int int `json:"int,omitempty"` +} + +type TestResourceStatus struct { +} diff --git a/internal/controllers/reconciliation/fixtures/v1/zz_generated.deepcopy.go b/internal/controllers/reconciliation/fixtures/v1/zz_generated.deepcopy.go new file mode 100644 index 00000000..d666b0de --- /dev/null +++ b/internal/controllers/reconciliation/fixtures/v1/zz_generated.deepcopy.go @@ -0,0 +1,124 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1 + +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 *TestResource) DeepCopyInto(out *TestResource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResource. +func (in *TestResource) DeepCopy() *TestResource { + if in == nil { + return nil + } + out := new(TestResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestResource) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResourceList) DeepCopyInto(out *TestResourceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TestResource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceList. +func (in *TestResourceList) DeepCopy() *TestResourceList { + if in == nil { + return nil + } + out := new(TestResourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestResourceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResourceSpec) DeepCopyInto(out *TestResourceSpec) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]*TestValue, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(TestValue) + **out = **in + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceSpec. +func (in *TestResourceSpec) DeepCopy() *TestResourceSpec { + if in == nil { + return nil + } + out := new(TestResourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResourceStatus) DeepCopyInto(out *TestResourceStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceStatus. +func (in *TestResourceStatus) DeepCopy() *TestResourceStatus { + if in == nil { + return nil + } + out := new(TestResourceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestValue) DeepCopyInto(out *TestValue) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestValue. +func (in *TestValue) DeepCopy() *TestValue { + if in == nil { + return nil + } + out := new(TestValue) + in.DeepCopyInto(out) + return out +} diff --git a/internal/controllers/reconciliation/integration_test.go b/internal/controllers/reconciliation/integration_test.go index c9605cad..58786389 100644 --- a/internal/controllers/reconciliation/integration_test.go +++ b/internal/controllers/reconciliation/integration_test.go @@ -10,12 +10,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" apiv1 "github.com/Azure/eno/api/v1" + testv1 "github.com/Azure/eno/internal/controllers/reconciliation/fixtures/v1" "github.com/Azure/eno/internal/controllers/synthesis" "github.com/Azure/eno/internal/reconstitution" "github.com/Azure/eno/internal/testutil" ) -func TestControllerBasics(t *testing.T) { +func TestControllerPodBasics(t *testing.T) { ctx := testutil.NewContext(t) mgr := testutil.NewManager(t) cli := mgr.GetClient() @@ -41,10 +42,16 @@ func TestControllerBasics(t *testing.T) { slice.Spec.Resources = []apiv1.Manifest{{ Manifest: `{ "apiVersion": "v1", - "kind": "ConfigMap", + "kind": "Pod", "metadata": { - "name": "test-configmap", + "name": "test-pod", "namespace": "default" + }, + "spec": { + "containers": [{ + "name": "test", + "image": "test-image-1" + }] } }`, }} @@ -52,13 +59,116 @@ func TestControllerBasics(t *testing.T) { slice.Spec.Resources = []apiv1.Manifest{{ Manifest: `{ "apiVersion": "v1", - "kind": "ConfigMap", + "kind": "Pod", + "metadata": { + "name": "test-pod", + "namespace": "default" + }, + "spec": { + "containers": [{ + "name": "test", + "image": "test-image-2" + }] + } + }`, + }} + default: + t.Fatalf("unknown pseudo-image: %s", s.Spec.Image) + } + return []*apiv1.ResourceSlice{slice} + }) + + require.NoError(t, New(rm, mgr.RestConfig)) + mgr.Start(t) + + syn := &apiv1.Synthesizer{} + syn.Name = "test-syn" + syn.Spec.Image = "create" + require.NoError(t, cli.Create(ctx, syn)) + + comp := &apiv1.Composition{} + comp.Name = "test-comp" + comp.Namespace = "default" + comp.Spec.Synthesizer.Name = syn.Name + require.NoError(t, cli.Create(ctx, comp)) + + t.Run("creation", func(t *testing.T) { + testutil.Eventually(t, func() bool { + pod := &corev1.Pod{} + pod.Name = "test-pod" + pod.Namespace = "default" + return cli.Get(ctx, client.ObjectKeyFromObject(pod), pod) == nil + }) + }) + + // we expect this to use strategic merge + t.Run("update", func(t *testing.T) { + err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { + if err := cli.Get(ctx, client.ObjectKeyFromObject(syn), syn); err != nil { + return err + } + syn.Spec.Image = "update" + return cli.Update(ctx, syn) + }) + require.NoError(t, err) + + testutil.Eventually(t, func() bool { + pod := &corev1.Pod{} + pod.Name = "test-pod" + pod.Namespace = "default" + require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(pod), pod)) + return pod.Spec.Containers[0].Image == "test-image-2" + }) + }) +} + +func TestControllerCRBasics(t *testing.T) { + ctx := testutil.NewContext(t) + mgr := testutil.NewManager(t) + cli := mgr.GetClient() + + require.NoError(t, synthesis.NewRolloutController(mgr.Manager, time.Millisecond)) + require.NoError(t, synthesis.NewStatusController(mgr.Manager)) + require.NoError(t, synthesis.NewPodLifecycleController(mgr.Manager, &synthesis.Config{ + WrapperImage: "test-wrapper", + MaxRestarts: 2, + Timeout: time.Second * 5, + })) + + rm, err := reconstitution.New(mgr.Manager, time.Millisecond) + require.NoError(t, err) + + testutil.NewPodController(t, mgr.Manager, func(c *apiv1.Composition, s *apiv1.Synthesizer) []*apiv1.ResourceSlice { + slice := &apiv1.ResourceSlice{} + slice.GenerateName = "test-" + slice.Namespace = "default" + slice.Spec.CompositionGeneration = c.Generation + switch s.Spec.Image { + case "create": + slice.Spec.Resources = []apiv1.Manifest{{ + Manifest: `{ + "apiVersion": "enotest.azure.io/v1", + "kind": "TestResource", + "metadata": { + "name": "test-resource", + "namespace": "default" + }, + "spec": { + "values": [{ "int": 123 }] + } + }`, + }} + case "update": + slice.Spec.Resources = []apiv1.Manifest{{ + Manifest: `{ + "apiVersion": "enotest.azure.io/v1", + "kind": "TestResource", "metadata": { - "name": "test-configmap", + "name": "test-resource", "namespace": "default" }, - "data": { - "test-key": "test-value" + "spec": { + "values": [{ "int": 234 }, { "int": 345 }] } }`, }} @@ -84,13 +194,14 @@ func TestControllerBasics(t *testing.T) { t.Run("creation", func(t *testing.T) { testutil.Eventually(t, func() bool { - cm := &corev1.ConfigMap{} - cm.Name = "test-configmap" - cm.Namespace = "default" - return cli.Get(ctx, client.ObjectKeyFromObject(cm), cm) == nil + cr := &testv1.TestResource{} + cr.Name = "test-resource" + cr.Namespace = "default" + return cli.Get(ctx, client.ObjectKeyFromObject(cr), cr) == nil }) }) + // we do not expect this to use strategic merge because CRs do not support it t.Run("update", func(t *testing.T) { err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { if err := cli.Get(ctx, client.ObjectKeyFromObject(syn), syn); err != nil { @@ -102,11 +213,11 @@ func TestControllerBasics(t *testing.T) { require.NoError(t, err) testutil.Eventually(t, func() bool { - cm := &corev1.ConfigMap{} - cm.Name = "test-configmap" - cm.Namespace = "default" - require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(cm), cm)) - return cm.Data != nil && cm.Data["test-key"] == "test-value" + cr := &testv1.TestResource{} + cr.Name = "test-resource" + cr.Namespace = "default" + require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(cr), cr)) + return len(cr.Spec.Values) == 2 && cr.Spec.Values[0].Int == 234 && cr.Spec.Values[1].Int == 345 }) }) } diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 113d84a7..fb0ae40f 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -26,6 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" apiv1 "github.com/Azure/eno/api/v1" + testv1 "github.com/Azure/eno/internal/controllers/reconciliation/fixtures/v1" "github.com/Azure/eno/internal/manager" ) @@ -66,7 +67,10 @@ func NewManager(t *testing.T) *Manager { root := filepath.Join(filepath.Dir(b), "..", "..") env := &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join(root, "api", "v1", "config", "crd")}, + CRDDirectoryPaths: []string{ + filepath.Join(root, "api", "v1", "config", "crd"), + filepath.Join(root, "internal", "controllers", "reconciliation", "fixtures", "v1", "config", "crd"), + }, ErrorIfCRDPathMissing: true, // We can't use KUBEBUILDER_ASSETS when also setting DOWNSTREAM_KUBEBUILDER_ASSETS @@ -88,6 +92,7 @@ func NewManager(t *testing.T) *Manager { MetricsAddr: "127.0.0.1:0", }) require.NoError(t, err) + require.NoError(t, testv1.SchemeBuilder.AddToScheme(mgr.GetScheme())) // test-specific CRDs m := &Manager{ Manager: mgr, @@ -102,6 +107,10 @@ func NewManager(t *testing.T) *Manager { downstreamEnv := &envtest.Environment{ BinaryAssetsDirectory: dir, + CRDDirectoryPaths: []string{ + filepath.Join(root, "internal", "controllers", "reconciliation", "fixtures", "v1", "config", "crd"), + }, + ErrorIfCRDPathMissing: true, } // k8s <1.13 will not start if these flags are set