diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml new file mode 100644 index 000000000..02e7154c2 --- /dev/null +++ b/.github/workflows/unit-test.yaml @@ -0,0 +1,20 @@ +name: unit-test + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + unit-test-basic: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-go@v3 + with: + go-version-file: "go.mod" + - name: Run basic unit tests + run: | + make test diff --git a/api/v1alpha1/operator_types.go b/api/v1alpha1/operator_types.go index 485806d29..f39276975 100644 --- a/api/v1alpha1/operator_types.go +++ b/api/v1alpha1/operator_types.go @@ -27,8 +27,22 @@ type OperatorSpec struct { PackageName string `json:"packageName"` } +const ( + // TODO(user): add more Types + TypeReady = "Ready" + + // TODO(user): add more Reasons + ReasonNotImplemented = "NotImplemented" +) + // OperatorStatus defines the observed state of Operator -type OperatorStatus struct{} +type OperatorStatus struct { + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} //+kubebuilder:object:root=true //+kubebuilder:resource:scope=Cluster diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index cd276d734..f54900db6 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -22,6 +22,7 @@ limitations under the License. package v1alpha1 import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -31,7 +32,7 @@ func (in *Operator) DeepCopyInto(out *Operator) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) out.Spec = in.Spec - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Operator. @@ -102,6 +103,13 @@ func (in *OperatorSpec) DeepCopy() *OperatorSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OperatorStatus) DeepCopyInto(out *OperatorStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperatorStatus. diff --git a/config/crd/bases/operators.operatorframework.io_operators.yaml b/config/crd/bases/operators.operatorframework.io_operators.yaml index e6584b095..894b7699c 100644 --- a/config/crd/bases/operators.operatorframework.io_operators.yaml +++ b/config/crd/bases/operators.operatorframework.io_operators.yaml @@ -44,6 +44,78 @@ spec: type: object status: description: OperatorStatus defines the observed state of Operator + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map type: object type: object served: true diff --git a/controllers/operator_controller.go b/controllers/operator_controller.go index f96366935..8f05fe648 100644 --- a/controllers/operator_controller.go +++ b/controllers/operator_controller.go @@ -19,7 +19,11 @@ package controllers import ( "context" + "k8s.io/apimachinery/pkg/api/equality" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + utilerrors "k8s.io/apimachinery/pkg/util/errors" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -47,7 +51,59 @@ type OperatorReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.13.1/pkg/reconcile func (r *OperatorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) + l := log.FromContext(ctx).WithName("reconcile") + l.V(1).Info("starting") + defer l.V(1).Info("ending") + + var existingOp = &operatorsv1alpha1.Operator{} + if err := r.Get(ctx, req.NamespacedName, existingOp); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + reconciledOp := existingOp.DeepCopy() + res, reconcileErr := r.reconcile(ctx, reconciledOp) + + // Do checks before any Update()s, as Update() may modify the resource structure! + updateStatus := !equality.Semantic.DeepEqual(existingOp.Status, reconciledOp.Status) + updateFinalizers := !equality.Semantic.DeepEqual(existingOp.Finalizers, reconciledOp.Finalizers) + + // Compare resources - ignoring status & metadata.finalizers + compareOp := reconciledOp.DeepCopy() + existingOp.Status, compareOp.Status = operatorsv1alpha1.OperatorStatus{}, operatorsv1alpha1.OperatorStatus{} + existingOp.Finalizers, compareOp.Finalizers = []string{}, []string{} + specDiffers := !equality.Semantic.DeepEqual(existingOp, compareOp) + + if updateStatus { + if updateErr := r.Status().Update(ctx, reconciledOp); updateErr != nil { + return res, utilerrors.NewAggregate([]error{reconcileErr, updateErr}) + } + } + + if specDiffers { + panic("spec or metadata changed by reconciler") + } + + if updateFinalizers { + if updateErr := r.Update(ctx, reconciledOp); updateErr != nil { + return res, utilerrors.NewAggregate([]error{reconcileErr, updateErr}) + } + } + + return res, reconcileErr +} + +// Helper function to do the actual reconcile +func (r *OperatorReconciler) reconcile(ctx context.Context, op *operatorsv1alpha1.Operator) (ctrl.Result, error) { + + // TODO(user): change ReasonNotImplemented when functionality added + readyCondition := metav1.Condition{ + Type: operatorsv1alpha1.TypeReady, + Status: metav1.ConditionFalse, + Reason: operatorsv1alpha1.ReasonNotImplemented, + Message: "The Reconcile operation is not implemented", + ObservedGeneration: op.GetGeneration(), + } + apimeta.SetStatusCondition(&op.Status.Conditions, readyCondition) // TODO(user): your logic here diff --git a/controllers/suite_test.go b/controllers/suite_test.go index 298c2c7b5..c67f6f236 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -14,23 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -package controllers +package controllers_test import ( + "context" + "fmt" "path/filepath" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" operatorsv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/controllers" //+kubebuilder:scaffold:imports ) @@ -78,3 +85,60 @@ var _ = AfterSuite(func() { err := testEnv.Stop() Expect(err).NotTo(HaveOccurred()) }) + +var _ = Describe("Reconcile Test", func() { + When("an Operator is created", func() { + var ( + operator *operatorsv1alpha1.Operator + ctx context.Context + opName string + pkgName string + err error + ) + BeforeEach(func() { + ctx = context.Background() + + opName = fmt.Sprintf("operator-test-%s", rand.String(8)) + pkgName = fmt.Sprintf("package-test-%s", rand.String(8)) + + operator = &operatorsv1alpha1.Operator{ + ObjectMeta: metav1.ObjectMeta{ + Name: opName, + }, + Spec: operatorsv1alpha1.OperatorSpec{ + PackageName: pkgName, + }, + } + err = k8sClient.Create(ctx, operator) + Expect(err).To(Not(HaveOccurred())) + + or := controllers.OperatorReconciler{ + k8sClient, + scheme.Scheme, + } + _, err = or.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: opName, + }, + }) + Expect(err).To(Not(HaveOccurred())) + }) + AfterEach(func() { + err = k8sClient.Delete(ctx, operator) + Expect(err).To(Not(HaveOccurred())) + }) + It("has a Condition created", func() { + getOperator := &operatorsv1alpha1.Operator{} + + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: opName, + }, getOperator) + Expect(err).To(Not(HaveOccurred())) + + // There should always be a "Ready" condition, regardless of Status. + conds := getOperator.Status.Conditions + Expect(conds).To(Not(BeEmpty())) + Expect(conds).To(ContainElement(HaveField("Type", operatorsv1alpha1.TypeReady))) + }) + }) +})