From b841f73a002cf4904e3829e5574f062fe286a4e8 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 17 Jan 2024 09:04:13 +0100 Subject: [PATCH] add lua script support (#106) * add lua script support * fix unit test * change lua function name * change lua function name * change to CustomHealth --- .dockerignore | 3 + Makefile | 35 ++++- PROJECT | 20 +++ api/v1alpha1/common_types.go | 27 ++++ api/v1alpha1/customhealth_types.go | 66 ++++++++++ api/v1alpha1/groupversion_info.go | 36 +++++ api/v1alpha1/zz_generated.deepcopy.go | 123 ++++++++++++++++++ .../deployments.plural.sh_customhealths.yaml | 122 +++++++++++++++++ .../deployment-operator/templates/rbac.yaml | 9 ++ cmd/main.go | 29 ++++- .../deployments.plural.sh_customhealths.yaml | 122 +++++++++++++++++ config/crd/kustomization.yaml | 23 ++++ config/crd/kustomizeconfig.yaml | 19 +++ config/rbac/role.yaml | 33 +++++ go.mod | 4 +- go.sum | 8 +- hack/boilerplate.go.txt | 15 +++ .../controller/customhealth_controller.go | 81 ++++++++++++ internal/controller/customhealth_scope.go | 48 +++++++ internal/controller/suite_test.go | 90 +++++++++++++ internal/utils/kubernetes.go | 17 +++ pkg/agent/agent.go | 8 ++ pkg/lua/funcs.go | 35 +++++ pkg/lua/lua.go | 117 +++++++++++++++++ pkg/manifests/template/helm_test.go | 2 +- pkg/sync/engine.go | 9 ++ pkg/sync/health.go | 16 +++ pkg/sync/loop.go | 1 - pkg/sync/status.go | 24 ++-- 29 files changed, 1124 insertions(+), 18 deletions(-) create mode 100644 .dockerignore create mode 100644 PROJECT create mode 100644 api/v1alpha1/common_types.go create mode 100644 api/v1alpha1/customhealth_types.go create mode 100644 api/v1alpha1/groupversion_info.go create mode 100644 api/v1alpha1/zz_generated.deepcopy.go create mode 100644 charts/deployment-operator/crds/deployments.plural.sh_customhealths.yaml create mode 100644 config/crd/bases/deployments.plural.sh_customhealths.yaml create mode 100644 config/crd/kustomization.yaml create mode 100644 config/crd/kustomizeconfig.yaml create mode 100644 config/rbac/role.yaml create mode 100644 hack/boilerplate.go.txt create mode 100644 internal/controller/customhealth_controller.go create mode 100644 internal/controller/customhealth_scope.go create mode 100644 internal/controller/suite_test.go create mode 100644 internal/utils/kubernetes.go create mode 100644 pkg/lua/funcs.go create mode 100644 pkg/lua/lua.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a3aab7af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +# More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file +# Ignore build and test binaries. +bin/ diff --git a/Makefile b/Makefile index bf5462cd..a5725e09 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,13 @@ IMG ?= deployment-agent:latest ENVTEST ?= $(shell which setup-envtest) +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p $(LOCALBIN) + ENVTEST_K8S_VERSION := 1.28.3 +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen include tools.mk @@ -22,9 +28,22 @@ PRE = --ensure ##@ General .PHONY: help -help: ## show help +help: ## Display this help. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) +controller-gen: ## Download controller-gen locally if necessary. + $(call go-get-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen@v0.11.3) + +##@ Development + +.PHONY: manifests +manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. + $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases + +.PHONY: generate +generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. + $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." + ##@ Run .PHONY: run @@ -86,3 +105,17 @@ envtest: --tool ## Download and install setup-envtest in the $GOPATH/bin .PHONY: mockery mockery: TOOL = mockery mockery: --tool + +# go-get-tool will 'go get' any package $2 and install it to $1. +PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) +define go-get-tool +@[ -f $(1) ] || { \ +set -e ;\ +TMP_DIR=$$(mktemp -d) ;\ +cd $$TMP_DIR ;\ +go mod init tmp ;\ +echo "Downloading $(2)" ;\ +GOBIN=$(PROJECT_DIR)/bin go install $(2) ;\ +rm -rf $$TMP_DIR ;\ +} +endef \ No newline at end of file diff --git a/PROJECT b/PROJECT new file mode 100644 index 00000000..6ab46407 --- /dev/null +++ b/PROJECT @@ -0,0 +1,20 @@ +# Code generated by tool. DO NOT EDIT. +# This file is used to track the info used to scaffold your project +# and allow the plugins properly work. +# More info: https://book.kubebuilder.io/reference/project-config.html +domain: plural.sh +layout: +- go.kubebuilder.io/v4 +projectName: kubebilder +repo: github.com/pluralsh/deployment-operator +resources: +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: plural.sh + group: deployments + kind: CustomHealth + path: github.com/pluralsh/deployment-operator/api/v1alpha1 + version: v1alpha1 +version: "3" diff --git a/api/v1alpha1/common_types.go b/api/v1alpha1/common_types.go new file mode 100644 index 00000000..5e4130df --- /dev/null +++ b/api/v1alpha1/common_types.go @@ -0,0 +1,27 @@ +package v1alpha1 + +type ConditionType string + +func (c ConditionType) String() string { + return string(c) +} + +const ( + ReadyConditionType ConditionType = "Ready" +) + +type ConditionReason string + +func (c ConditionReason) String() string { + return string(c) +} + +const ( + ReadyConditionReason ConditionReason = "Ready" +) + +type ConditionMessage string + +func (c ConditionMessage) String() string { + return string(c) +} diff --git a/api/v1alpha1/customhealth_types.go b/api/v1alpha1/customhealth_types.go new file mode 100644 index 00000000..b5125b9d --- /dev/null +++ b/api/v1alpha1/customhealth_types.go @@ -0,0 +1,66 @@ +/* +Copyright 2024. + +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. +*/ + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// CustomHealthSpec defines the desired state of CustomHealth +type CustomHealthSpec struct { + Script string `json:"script,omitempty"` +} + +// CustomHealthStatus defines the observed state of CustomHealth +type CustomHealthStatus struct { + // Represents the observations of a HealthConvert current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// CustomHealth is the Schema for the HealthConverts API +type CustomHealth struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CustomHealthSpec `json:"spec,omitempty"` + Status CustomHealthStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// CustomHealthList contains a list of CustomHealth +type CustomHealthList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []CustomHealth `json:"items"` +} + +func init() { + SchemeBuilder.Register(&CustomHealth{}, &CustomHealthList{}) +} + +func (c *CustomHealth) SetCondition(condition metav1.Condition) { + meta.SetStatusCondition(&c.Status.Conditions, condition) +} diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 00000000..5ee73149 --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,36 @@ +/* +Copyright 2024. + +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. +*/ + +// Package v1alpha1 contains API Schema definitions for the deployments v1alpha1 API group +// +kubebuilder:object:generate=true +// +groupName=deployments.plural.sh +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "deployments.plural.sh", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..7b3aac23 --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,123 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2024. + +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + 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 *CustomHealth) DeepCopyInto(out *CustomHealth) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomHealth. +func (in *CustomHealth) DeepCopy() *CustomHealth { + if in == nil { + return nil + } + out := new(CustomHealth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CustomHealth) 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 *CustomHealthList) DeepCopyInto(out *CustomHealthList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CustomHealth, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomHealthList. +func (in *CustomHealthList) DeepCopy() *CustomHealthList { + if in == nil { + return nil + } + out := new(CustomHealthList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CustomHealthList) 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 *CustomHealthSpec) DeepCopyInto(out *CustomHealthSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomHealthSpec. +func (in *CustomHealthSpec) DeepCopy() *CustomHealthSpec { + if in == nil { + return nil + } + out := new(CustomHealthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CustomHealthStatus) DeepCopyInto(out *CustomHealthStatus) { + *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 CustomHealthStatus. +func (in *CustomHealthStatus) DeepCopy() *CustomHealthStatus { + if in == nil { + return nil + } + out := new(CustomHealthStatus) + in.DeepCopyInto(out) + return out +} diff --git a/charts/deployment-operator/crds/deployments.plural.sh_customhealths.yaml b/charts/deployment-operator/crds/deployments.plural.sh_customhealths.yaml new file mode 100644 index 00000000..a9437e6d --- /dev/null +++ b/charts/deployment-operator/crds/deployments.plural.sh_customhealths.yaml @@ -0,0 +1,122 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.3 + creationTimestamp: null + name: customhealths.deployments.plural.sh +spec: + group: deployments.plural.sh + names: + kind: CustomHealth + listKind: CustomHealthList + plural: customhealths + singular: customhealth + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: CustomHealth is the Schema for the HealthConverts API + 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: + description: CustomHealthSpec defines the desired state of CustomHealth + properties: + script: + type: string + type: object + status: + description: CustomHealthStatus defines the observed state of CustomHealth + properties: + conditions: + description: Represents the observations of a HealthConvert current + state. + 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 + storage: true + subresources: + status: {} diff --git a/charts/deployment-operator/templates/rbac.yaml b/charts/deployment-operator/templates/rbac.yaml index af0ec30a..c08c9c91 100644 --- a/charts/deployment-operator/templates/rbac.yaml +++ b/charts/deployment-operator/templates/rbac.yaml @@ -26,6 +26,15 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["delete"] +- apiGroups: ["deployments.plural.sh"] + resources: ["customhealths"] + verbs: ["create","delete","get", "list", "patch", "update", "watch"] +- apiGroups: ["deployments.plural.sh"] + resources: ["customhealths/finalizers"] + verbs: ["update"] +- apiGroups: ["deployments.plural.sh"] + resources: ["customhealths/status"] + verbs: ["get", "patch", "update", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/cmd/main.go b/cmd/main.go index eb014f50..351bd7d5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,11 +5,15 @@ import ( "os" "time" + deploymentsv1alpha1 "github.com/pluralsh/deployment-operator/api/v1alpha1" + "github.com/pluralsh/deployment-operator/internal/controller" "github.com/pluralsh/deployment-operator/pkg/agent" "github.com/pluralsh/deployment-operator/pkg/log" "github.com/pluralsh/deployment-operator/pkg/manifests/template" "github.com/pluralsh/deployment-operator/pkg/sync" "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" @@ -21,6 +25,13 @@ var ( setupLog = log.Logger ) +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + utilruntime.Must(deploymentsv1alpha1.AddToScheme(scheme)) + //+kubebuilder:scaffold:scheme +} + type controllerRunOptions struct { enableLeaderElection bool metricsAddr string @@ -83,10 +94,6 @@ func main() { setupLog.Error(err, "unable to create manager") os.Exit(1) } - if err = mgr.AddHealthzCheck("ping", healthz.Ping); err != nil { - setupLog.Error(err, "unable to create health check") - os.Exit(1) - } a, err := agent.New(mgr.GetConfig(), refresh, pTimeout, opt.consoleUrl, opt.deployToken, opt.clusterId) if err != nil { @@ -98,6 +105,20 @@ func main() { os.Exit(1) } + if err = (&controller.CustomHealthReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Agent: a, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "HealthConvert") + } + //+kubebuilder:scaffold:builder + + if err = mgr.AddHealthzCheck("ping", healthz.Ping); err != nil { + setupLog.Error(err, "unable to create health check") + os.Exit(1) + } + ctx := ctrl.SetupSignalHandler() setupLog.Info("starting manager") if err := mgr.Start(ctx); err != nil { diff --git a/config/crd/bases/deployments.plural.sh_customhealths.yaml b/config/crd/bases/deployments.plural.sh_customhealths.yaml new file mode 100644 index 00000000..a9437e6d --- /dev/null +++ b/config/crd/bases/deployments.plural.sh_customhealths.yaml @@ -0,0 +1,122 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.3 + creationTimestamp: null + name: customhealths.deployments.plural.sh +spec: + group: deployments.plural.sh + names: + kind: CustomHealth + listKind: CustomHealthList + plural: customhealths + singular: customhealth + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: CustomHealth is the Schema for the HealthConverts API + 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: + description: CustomHealthSpec defines the desired state of CustomHealth + properties: + script: + type: string + type: object + status: + description: CustomHealthStatus defines the observed state of CustomHealth + properties: + conditions: + description: Represents the observations of a HealthConvert current + state. + 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 + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml new file mode 100644 index 00000000..0bf0d699 --- /dev/null +++ b/config/crd/kustomization.yaml @@ -0,0 +1,23 @@ +# This kustomization.yaml is not intended to be run by itself, +# since it depends on service name and namespace that are out of this kustomize package. +# It should be run by config/default +resources: +- bases/deployments.plural.sh_luascripts.yaml +#+kubebuilder:scaffold:crdkustomizeresource + +patches: +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. +# patches here are for enabling the conversion webhook for each CRD +#- path: patches/webhook_in_luascripts.yaml +#+kubebuilder:scaffold:crdkustomizewebhookpatch + +# [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. +# patches here are for enabling the CA injection for each CRD +#- path: patches/cainjection_in_luascripts.yaml +#+kubebuilder:scaffold:crdkustomizecainjectionpatch + +# [WEBHOOK] To enable webhook, uncomment the following section +# the following config is for teaching kustomize how to do kustomization for CRDs. + +#configurations: +#- kustomizeconfig.yaml diff --git a/config/crd/kustomizeconfig.yaml b/config/crd/kustomizeconfig.yaml new file mode 100644 index 00000000..ec5c150a --- /dev/null +++ b/config/crd/kustomizeconfig.yaml @@ -0,0 +1,19 @@ +# This file is for teaching kustomize how to substitute name and namespace reference in CRD +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/name + +namespace: +- kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/namespace + create: false + +varReference: +- path: metadata/annotations diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml new file mode 100644 index 00000000..b36fe992 --- /dev/null +++ b/config/rbac/role.yaml @@ -0,0 +1,33 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + name: manager-role +rules: +- apiGroups: + - deployments.plural.sh + resources: + - healthconverts + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - deployments.plural.sh + resources: + - healthconverts/finalizers + verbs: + - update +- apiGroups: + - deployments.plural.sh + resources: + - healthconverts/status + verbs: + - get + - patch + - update diff --git a/go.mod b/go.mod index 7dde1a37..d257b87d 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/fluxcd/flagger v1.35.0 github.com/gin-gonic/gin v1.7.7 github.com/gofrs/flock v0.8.1 + github.com/mitchellh/mapstructure v1.5.0 github.com/onsi/ginkgo/v2 v2.12.1 github.com/onsi/gomega v1.27.10 github.com/orcaman/concurrent-map/v2 v2.0.1 @@ -19,6 +20,7 @@ require ( github.com/samber/lo v1.38.1 github.com/spf13/pflag v1.0.5 github.com/vektra/mockery/v2 v2.39.0 + github.com/yuin/gopher-lua v1.1.1 go.uber.org/zap v1.26.0 helm.sh/helm/v3 v3.11.2 k8s.io/api v0.27.7 @@ -27,6 +29,7 @@ require ( k8s.io/client-go v0.27.7 k8s.io/klog/v2 v2.110.1 k8s.io/kubectl v0.26.0 + layeh.com/gopher-luar v1.0.11 sigs.k8s.io/cli-utils v0.35.0 sigs.k8s.io/controller-runtime v0.15.3 sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20231215020716-1b80b9629af8 @@ -122,7 +125,6 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/spdystream v0.2.0 // indirect diff --git a/go.sum b/go.sum index 9602a029..2bd14cb1 100644 --- a/go.sum +++ b/go.sum @@ -576,8 +576,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= -github.com/pluralsh/console-client-go v0.0.57 h1:XVs2fSrHCU/gB79DKqmsHF9Fo/D9oy8R69oSewFgGfI= -github.com/pluralsh/console-client-go v0.0.57/go.mod h1:u/RjzXE3wtl3L6wiWxwhQHSpxFX46+EYvpkss2mALN4= github.com/pluralsh/console-client-go v0.0.64 h1:IZDbjDS+VMHVpIabcx2YYsBMzvtefbBv1LAVxsi1aNw= github.com/pluralsh/console-client-go v0.0.64/go.mod h1:u/RjzXE3wtl3L6wiWxwhQHSpxFX46+EYvpkss2mALN4= github.com/pluralsh/gophoenix v0.1.3-0.20231201014135-dff1b4309e34 h1:ab2PN+6if/Aq3/sJM0AVdy1SYuMAnq4g20VaKhTm/Bw= @@ -723,6 +721,9 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE= @@ -898,6 +899,7 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1224,6 +1226,8 @@ k8s.io/kubectl v0.26.0 h1:xmrzoKR9CyNdzxBmXV7jW9Ln8WMrwRK6hGbbf69o4T0= k8s.io/kubectl v0.26.0/go.mod h1:eInP0b+U9XUJWSYeU9XZnTA+cVYuWyl3iYPGtru0qhQ= k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk= k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +layeh.com/gopher-luar v1.0.11 h1:8zJudpKI6HWkoh9eyyNFaTM79PY6CAPcIr6X/KTiliw= +layeh.com/gopher-luar v1.0.11/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk= oras.land/oras-go v1.2.2 h1:0E9tOHUfrNH7TCDk5KU0jVBEzCqbfdyuVfGmJ7ZeRPE= oras.land/oras-go v1.2.2/go.mod h1:Apa81sKoZPpP7CDciE006tSZ0x3Q3+dOoBcMZ/aNxvw= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt new file mode 100644 index 00000000..ff72ff2a --- /dev/null +++ b/hack/boilerplate.go.txt @@ -0,0 +1,15 @@ +/* +Copyright 2024. + +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. +*/ \ No newline at end of file diff --git a/internal/controller/customhealth_controller.go b/internal/controller/customhealth_controller.go new file mode 100644 index 00000000..59f76345 --- /dev/null +++ b/internal/controller/customhealth_controller.go @@ -0,0 +1,81 @@ +/* +Copyright 2024. + +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. +*/ + +package controller + +import ( + "context" + + "github.com/pluralsh/deployment-operator/api/v1alpha1" + "github.com/pluralsh/deployment-operator/internal/utils" + "github.com/pluralsh/deployment-operator/pkg/agent" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// CustomHealthReconciler reconciles a LuaScript object +type CustomHealthReconciler struct { + client.Client + Scheme *runtime.Scheme + Agent *agent.Agent +} + +//+kubebuilder:rbac:groups=deployments.plural.sh,resources=customhealths,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=deployments.plural.sh,resources=customhealths/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=deployments.plural.sh,resources=customhealths/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.3/pkg/reconcile +func (r *CustomHealthReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ reconcile.Result, reterr error) { + logger := log.FromContext(ctx) + script := &v1alpha1.CustomHealth{} + if err := r.Get(ctx, req.NamespacedName, script); err != nil { + logger.Error(err, "Unable to fetch LuaScript") + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Ensure that status updates will always be persisted when exiting this function. + scope, err := NewClusterScope(ctx, r.Client, script) + if err != nil { + logger.Error(err, "Failed to create cluster scope") + utils.MarkCondition(script.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error()) + return ctrl.Result{}, err + } + defer func() { + if err := scope.PatchObject(); err != nil && reterr == nil { + reterr = err + } + }() + + r.Agent.SetLuaScript(script.Spec.Script) + utils.MarkCondition(script.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionTrue, v1alpha1.ReadyConditionReason, "") + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *CustomHealthReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.CustomHealth{}). + Complete(r) +} diff --git a/internal/controller/customhealth_scope.go b/internal/controller/customhealth_scope.go new file mode 100644 index 00000000..075fdefb --- /dev/null +++ b/internal/controller/customhealth_scope.go @@ -0,0 +1,48 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "reflect" + + "github.com/pluralsh/deployment-operator/api/v1alpha1" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type LuaScriptScope struct { + Client client.Client + HealthConvert *v1alpha1.CustomHealth + ctx context.Context +} + +func (p *LuaScriptScope) PatchObject() error { + + key := client.ObjectKeyFromObject(p.HealthConvert) + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + oldScript := &v1alpha1.CustomHealth{} + if err := p.Client.Get(p.ctx, key, oldScript); err != nil { + return fmt.Errorf("could not fetch current %s/%s state, got error: %+v", oldScript.GetName(), oldScript.GetNamespace(), err) + } + + if reflect.DeepEqual(oldScript.Status, p.HealthConvert.Status) { + return nil + } + + return p.Client.Status().Patch(p.ctx, p.HealthConvert, client.MergeFrom(oldScript)) + }) + +} + +func NewClusterScope(ctx context.Context, client client.Client, luaScript *v1alpha1.CustomHealth) (*LuaScriptScope, error) { + if luaScript == nil { + return nil, errors.New("failed to create new cluster scope, got nil cluster") + } + return &LuaScriptScope{ + Client: client, + HealthConvert: luaScript, + ctx: ctx, + }, nil +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go new file mode 100644 index 00000000..c33d73be --- /dev/null +++ b/internal/controller/suite_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2024. + +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. +*/ + +package controller + +import ( + "fmt" + "path/filepath" + "runtime" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "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" + + deploymentsv1alpha1 "github.com/pluralsh/deployment-operator/api/v1alpha1" + //+kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var testEnv *envtest.Environment + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + + // The BinaryAssetsDirectory is only required if you want to run the tests directly + // without call the makefile target test. If not informed it will look for the + // default path defined in controller-runtime which is /usr/local/kubebuilder/. + // Note that you must have the required binaries setup under the bin directory to perform + // the tests directly. When we run make test it will be setup and used automatically. + BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", + fmt.Sprintf("1.28.3-%s-%s", runtime.GOOS, runtime.GOARCH)), + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = deploymentsv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/internal/utils/kubernetes.go b/internal/utils/kubernetes.go new file mode 100644 index 00000000..2d0333f3 --- /dev/null +++ b/internal/utils/kubernetes.go @@ -0,0 +1,17 @@ +package utils + +import ( + "fmt" + + "github.com/pluralsh/deployment-operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func MarkCondition(set func(condition metav1.Condition), conditionType v1alpha1.ConditionType, conditionStatus metav1.ConditionStatus, conditionReason v1alpha1.ConditionReason, message string, messageArgs ...interface{}) { + set(metav1.Condition{ + Type: conditionType.String(), + Status: conditionStatus, + Reason: conditionReason.String(), + Message: fmt.Sprintf(message, messageArgs...), + }) +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 46ca2d9e..dbfa2c59 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -143,6 +143,14 @@ func (agent *Agent) SetupWithManager() error { return nil } +func (agent *Agent) GetLuaScript() string { + return agent.engine.GetLuaScript() +} + +func (agent *Agent) SetLuaScript(script string) { + agent.engine.SetLuaScript(script) +} + func newFactory(cfg *rest.Config) util.Factory { kubeConfigFlags := genericclioptions.NewConfigFlags(true).WithDeprecatedPasswordFlag() kubeConfigFlags.WithDiscoveryQPS(cfg.QPS).WithDiscoveryBurst(cfg.Burst) diff --git a/pkg/lua/funcs.go b/pkg/lua/funcs.go new file mode 100644 index 00000000..360c5e47 --- /dev/null +++ b/pkg/lua/funcs.go @@ -0,0 +1,35 @@ +package lua + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +type Status struct { + Conditions []metav1.Condition +} + +func statusConditionExists(s map[string]interface{}, condition string) bool { + sts := Status{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(s, &sts); err != nil { + return false + } + + return meta.FindStatusCondition(sts.Conditions, condition) != nil +} + +func isStatusConditionTrue(s map[string]interface{}, condition string) bool { + sts := Status{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(s, &sts); err != nil { + return false + } + + if meta.FindStatusCondition(sts.Conditions, condition) != nil { + if meta.IsStatusConditionTrue(sts.Conditions, condition) { + return true + } + } + + return false +} diff --git a/pkg/lua/lua.go b/pkg/lua/lua.go new file mode 100644 index 00000000..7c0e4895 --- /dev/null +++ b/pkg/lua/lua.go @@ -0,0 +1,117 @@ +package lua + +import ( + "errors" + "fmt" + "regexp" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/mitchellh/mapstructure" + lua "github.com/yuin/gopher-lua" + luar "layeh.com/gopher-luar" +) + +func ExecuteLua(vals map[string]interface{}, tplate string) (map[string]interface{}, error) { + output := map[string]interface{}{} + L := lua.NewState() + defer L.Close() + + L.SetGlobal("Obj", luar.New(L, vals)) + + for name, function := range GetFuncMap() { + L.SetGlobal(name, luar.New(L, function)) + } + for name, function := range sprig.GenericFuncMap() { + L.SetGlobal(name, luar.New(L, function)) + } + + if err := L.DoString(tplate); err != nil { + return nil, err + } + outTable, ok := L.GetGlobal("healthStatus").(*lua.LTable) + if !ok { + return nil, fmt.Errorf("the output variable is missing in the lua script") + } + if err := MapLua(outTable, &output); err != nil { + return nil, err + } + + return output, nil + +} + +func GetFuncMap() template.FuncMap { + funcs := sprig.TxtFuncMap() + funcs["isStatusConditionTrue"] = isStatusConditionTrue + funcs["statusConditionExists"] = statusConditionExists + return funcs +} + +// Mapper maps a lua table to a Go struct pointer. +type Mapper struct { +} + +// MapLua maps the lua table to the given struct pointer with default options. +func MapLua(tbl *lua.LTable, st interface{}) error { + return NewMapper().Map(tbl, st) +} + +// NewMapper returns a new mapper. +func NewMapper() *Mapper { + + return &Mapper{} +} + +// Map maps the lua table to the given struct pointer. +func (mapper *Mapper) Map(tbl *lua.LTable, st interface{}) error { + mp, ok := ToGoValue(tbl).(map[interface{}]interface{}) + if !ok { + return errors.New("arguments #1 must be a table, but got an array") + } + config := &mapstructure.DecoderConfig{ + WeaklyTypedInput: true, + Result: st, + } + decoder, err := mapstructure.NewDecoder(config) + if err != nil { + return err + } + return decoder.Decode(mp) +} + +// ToGoValue converts the given LValue to a Go object. +func ToGoValue(lv lua.LValue) interface{} { + switch v := lv.(type) { + case *lua.LNilType: + return nil + case lua.LBool: + return bool(v) + case lua.LString: + return trimQuotes(string(v)) + case lua.LNumber: + return float64(v) + case *lua.LTable: + maxn := v.MaxN() + if maxn == 0 { // table + ret := make(map[interface{}]interface{}) + v.ForEach(func(key, value lua.LValue) { + keystr := fmt.Sprint(ToGoValue(key)) + ret[keystr] = ToGoValue(value) + }) + return ret + } else { // array + ret := make([]interface{}, 0, maxn) + for i := 1; i <= maxn; i++ { + ret = append(ret, ToGoValue(v.RawGetInt(i))) + } + return ret + } + default: + return v + } +} + +func trimQuotes(s string) interface{} { + return regexp.MustCompile(`^"(.*)"$`).ReplaceAllString(s, "$1") +} diff --git a/pkg/manifests/template/helm_test.go b/pkg/manifests/template/helm_test.go index 182f68d8..e880ecf4 100644 --- a/pkg/manifests/template/helm_test.go +++ b/pkg/manifests/template/helm_test.go @@ -60,7 +60,7 @@ var _ = Describe("Helm template", func() { It("should successfully render the helm template", func() { resp, err := NewHelm(dir).Render(svc, utilFactory) Expect(err).NotTo(HaveOccurred()) - Expect(len(resp)).To(Equal(6)) + Expect(len(resp)).To(Equal(7)) }) }) diff --git a/pkg/sync/engine.go b/pkg/sync/engine.go index 83e903e6..a82f1a32 100644 --- a/pkg/sync/engine.go +++ b/pkg/sync/engine.go @@ -28,6 +28,7 @@ type Engine struct { destroyer *apply.Destroyer utilFactory util.Factory processingTimeout time.Duration + luaScript string } func New(utilFactory util.Factory, invFactory inventory.ClientFactory, applier *applier.Applier, destroyer *apply.Destroyer, client *client.Client, svcQueue workqueue.RateLimitingInterface, svcCache *client.ServiceCache, manCache *manifests.ManifestCache, processingTimeout time.Duration) *Engine { @@ -60,3 +61,11 @@ func (engine *Engine) WipeCache() { engine.svcCache.Wipe() engine.manifestCache.Wipe() } + +func (engine *Engine) GetLuaScript() string { + return engine.luaScript +} + +func (engine *Engine) SetLuaScript(script string) { + engine.luaScript = script +} diff --git a/pkg/sync/health.go b/pkg/sync/health.go index c5a2e0be..d7cca402 100644 --- a/pkg/sync/health.go +++ b/pkg/sync/health.go @@ -6,6 +6,7 @@ import ( "strings" flaggerv1beta1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1" + "github.com/pluralsh/deployment-operator/pkg/lua" appsv1 "k8s.io/api/apps/v1" autoscalingv1 "k8s.io/api/autoscaling/v1" autoscalingv2 "k8s.io/api/autoscaling/v2" @@ -756,3 +757,18 @@ func getOtherHealth(obj *unstructured.Unstructured) (*HealthStatus, error) { return nil, nil } + +func (engine *Engine) getLuaHealthConvert(obj *unstructured.Unstructured) (*HealthStatus, error) { + out, err := lua.ExecuteLua(obj.Object, engine.luaScript) + if err != nil { + return nil, err + } + healthStatus := &HealthStatus{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(out, healthStatus); err != nil { + return nil, err + } + if healthStatus.Status == "" && healthStatus.Message == "" { + return nil, nil + } + return healthStatus, nil +} diff --git a/pkg/sync/loop.go b/pkg/sync/loop.go index fc3c3d02..639382a7 100644 --- a/pkg/sync/loop.go +++ b/pkg/sync/loop.go @@ -145,7 +145,6 @@ func (engine *Engine) processItem(item interface{}) error { // if changed, err := engine.DryRunStatus(id, svc.Name, svc.Namespace, ch, vcache); !changed || err != nil { // return err // } - options.DryRunStrategy = common.DryRunNone ch := engine.applier.Run(ctx, inv, manifests, options) return engine.UpdateApplyStatus(id, svc.Name, svc.Namespace, ch, false, vcache) diff --git a/pkg/sync/status.go b/pkg/sync/status.go index 977e000b..2c4a79c6 100644 --- a/pkg/sync/status.go +++ b/pkg/sync/status.go @@ -42,7 +42,7 @@ type HealthStatus struct { } // GetResourceHealth returns the health of a k8s resource -func getResourceHealth(obj *unstructured.Unstructured) (health *HealthStatus, err error) { +func (engine *Engine) getResourceHealth(obj *unstructured.Unstructured) (health *HealthStatus, err error) { if obj.GetDeletionTimestamp() != nil { return &HealthStatus{ Status: HealthStatusProgressing, @@ -50,7 +50,7 @@ func getResourceHealth(obj *unstructured.Unstructured) (health *HealthStatus, er }, nil } - if healthCheck := GetHealthCheckFunc(obj.GroupVersionKind()); healthCheck != nil { + if healthCheck := engine.GetHealthCheckFunc(obj.GroupVersionKind()); healthCheck != nil { if health, err = healthCheck(obj); err != nil { health = &HealthStatus{ Status: HealthStatusUnknown, @@ -63,7 +63,7 @@ func getResourceHealth(obj *unstructured.Unstructured) (health *HealthStatus, er } // GetHealthCheckFunc returns built-in health check function or nil if health check is not supported -func GetHealthCheckFunc(gvk schema.GroupVersionKind) func(obj *unstructured.Unstructured) (*HealthStatus, error) { +func (engine *Engine) GetHealthCheckFunc(gvk schema.GroupVersionKind) func(obj *unstructured.Unstructured) (*HealthStatus, error) { switch gvk.Group { case "apps": switch gvk.Kind { @@ -106,6 +106,11 @@ func GetHealthCheckFunc(gvk schema.GroupVersionKind) func(obj *unstructured.Unst return getHPAHealth } } + + if engine.GetLuaScript() != "" { + return engine.getLuaHealthConvert + } + return getOtherHealth } @@ -138,6 +143,7 @@ func (engine *Engine) UpdatePruneStatus(id, name, namespace string, ch <-chan ev var err error statusCollector := &StatusCollector{ latestStatus: make(map[object.ObjMetadata]event.StatusEvent), + engine: engine, } for e := range ch { @@ -208,6 +214,7 @@ func (engine *Engine) UpdateApplyStatus(id, name, namespace string, ch <-chan ev var err error statusCollector := &StatusCollector{ latestStatus: make(map[object.ObjMetadata]event.StatusEvent), + engine: engine, } for e := range ch { @@ -266,7 +273,7 @@ func (engine *Engine) UpdateApplyStatus(id, name, namespace string, ch <-chan ev return nil } -func fromSyncResult(e event.StatusEvent, vcache map[manifests.GroupName]string) *console.ComponentAttributes { +func (engine *Engine) fromSyncResult(e event.StatusEvent, vcache map[manifests.GroupName]string) *console.ComponentAttributes { if e.Resource == nil { return nil } @@ -289,12 +296,12 @@ func fromSyncResult(e event.StatusEvent, vcache map[manifests.GroupName]string) Name: e.Resource.GetName(), Version: version, Synced: e.PollResourceInfo.Status == status.CurrentStatus, - State: toStatus(e.Resource), + State: engine.toStatus(e.Resource), } } -func toStatus(obj *unstructured.Unstructured) *console.ComponentState { - h, _ := getResourceHealth(obj) +func (engine *Engine) toStatus(obj *unstructured.Unstructured) *console.ComponentState { + h, _ := engine.getResourceHealth(obj) if h == nil { return nil } @@ -340,6 +347,7 @@ func errorAttributes(source string, err error) *console.ServiceErrorAttributes { type StatusCollector struct { latestStatus map[object.ObjMetadata]event.StatusEvent + engine *Engine } func (sc *StatusCollector) updateStatus(id object.ObjMetadata, se event.StatusEvent) { @@ -349,7 +357,7 @@ func (sc *StatusCollector) updateStatus(id object.ObjMetadata, se event.StatusEv func (sc *StatusCollector) Components(vcache map[manifests.GroupName]string) []*console.ComponentAttributes { components := []*console.ComponentAttributes{} for _, v := range sc.latestStatus { - consoleAttr := fromSyncResult(v, vcache) + consoleAttr := sc.engine.fromSyncResult(v, vcache) if consoleAttr != nil { components = append(components, consoleAttr) }