diff --git a/README.md b/README.md index 27bb24622..533627258 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ vSphere API from Kubernetes objects, e.g. a `Job`. - `VSphereSource` to create VMware vSphere (vCenter) event sources - `VSphereBinding` to inject VMware vSphere (vCenter) credentials +- `HorizonSource` to create VMware Horizon event sources -## Install Tanzu Sources for Knative +## Install Tanzu Sources CRDs for Knative ### Install via Release (`latest`) @@ -38,15 +39,15 @@ Install the CRD providing the control / dataplane for the various `Sources` and ```shell # define environment variables accordingly, e.g. when using kind -# export KIND_CLUSTER_NAME=horizon -# export KO_DOCKER_REPO=kind.local +export KIND_CLUSTER_NAME=vmware +export KO_DOCKER_REPO=kind.local ko apply -BRf config ``` ## Examples -To see examples of the Source and Binding in action, check out our +To see examples of the `Sources` and `Bindings` in action, check out our [samples](./samples/README.md) directory. ## Basic `VSphereSource` Example @@ -587,8 +588,8 @@ kubectl get vspheresource NAME SOURCE SINK READY REASON example-vc-source https://my-vc.corp.local http://broker-ingress.knative-eventing.svc.cluster.local/default/example-broker True -kubectl rollout restart deployment/example-vc-source-deployment -deployment.apps/example-vc-source-deployment restarted +kubectl rollout restart deployment/example-vc-source-adapter +deployment.apps/example-vc-source-adapter restarted ``` ⚠️ **Note:** To avoid losing events due to this (brief) downtime, consider diff --git a/cmd/horizon-adapter/kodata/HEAD b/cmd/horizon-adapter/kodata/HEAD new file mode 120000 index 000000000..8f63681d3 --- /dev/null +++ b/cmd/horizon-adapter/kodata/HEAD @@ -0,0 +1 @@ +../../../.git/HEAD \ No newline at end of file diff --git a/cmd/horizon-adapter/kodata/LICENSE b/cmd/horizon-adapter/kodata/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/cmd/horizon-adapter/kodata/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/cmd/horizon-adapter/kodata/refs b/cmd/horizon-adapter/kodata/refs new file mode 120000 index 000000000..739d35bf9 --- /dev/null +++ b/cmd/horizon-adapter/kodata/refs @@ -0,0 +1 @@ +../../../.git/refs \ No newline at end of file diff --git a/cmd/horizon-adapter/main.go b/cmd/horizon-adapter/main.go new file mode 100644 index 000000000..4d0b375e7 --- /dev/null +++ b/cmd/horizon-adapter/main.go @@ -0,0 +1,20 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package main + +import ( + "knative.dev/eventing/pkg/adapter/v2" + + myadapter "github.com/vmware-tanzu/sources-for-knative/pkg/horizon" +) + +const ( + adapterName = "horizon-source-adapter" +) + +func main() { + adapter.Main(adapterName, myadapter.NewEnv, myadapter.NewAdapter) +} diff --git a/cmd/horizon-controller/kodata/HEAD b/cmd/horizon-controller/kodata/HEAD new file mode 120000 index 000000000..8f63681d3 --- /dev/null +++ b/cmd/horizon-controller/kodata/HEAD @@ -0,0 +1 @@ +../../../.git/HEAD \ No newline at end of file diff --git a/cmd/horizon-controller/kodata/LICENSE b/cmd/horizon-controller/kodata/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/cmd/horizon-controller/kodata/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/cmd/horizon-controller/kodata/refs b/cmd/horizon-controller/kodata/refs new file mode 120000 index 000000000..739d35bf9 --- /dev/null +++ b/cmd/horizon-controller/kodata/refs @@ -0,0 +1 @@ +../../../.git/refs \ No newline at end of file diff --git a/cmd/horizon-controller/main.go b/cmd/horizon-controller/main.go new file mode 100644 index 000000000..22060ccf4 --- /dev/null +++ b/cmd/horizon-controller/main.go @@ -0,0 +1,22 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package main + +import ( + // The set of controllers this controller process runs. + "github.com/vmware-tanzu/sources-for-knative/pkg/reconciler/horizonsource" + + // This defines the shared main for injected controllers. + "knative.dev/pkg/injection/sharedmain" +) + +const ( + controllerName = "horizon-source-controller" +) + +func main() { + sharedmain.Main(controllerName, horizonsource.NewController) +} diff --git a/cmd/horizon-webhook/kodata/HEAD b/cmd/horizon-webhook/kodata/HEAD new file mode 120000 index 000000000..8f63681d3 --- /dev/null +++ b/cmd/horizon-webhook/kodata/HEAD @@ -0,0 +1 @@ +../../../.git/HEAD \ No newline at end of file diff --git a/cmd/horizon-webhook/kodata/LICENSE b/cmd/horizon-webhook/kodata/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/cmd/horizon-webhook/kodata/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/cmd/horizon-webhook/kodata/refs b/cmd/horizon-webhook/kodata/refs new file mode 120000 index 000000000..739d35bf9 --- /dev/null +++ b/cmd/horizon-webhook/kodata/refs @@ -0,0 +1 @@ +../../../.git/refs \ No newline at end of file diff --git a/cmd/horizon-webhook/main.go b/cmd/horizon-webhook/main.go new file mode 100644 index 000000000..fc1db5f48 --- /dev/null +++ b/cmd/horizon-webhook/main.go @@ -0,0 +1,122 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package main + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime/schema" + "knative.dev/pkg/configmap" + "knative.dev/pkg/controller" + "knative.dev/pkg/injection/sharedmain" + "knative.dev/pkg/logging" + "knative.dev/pkg/metrics" + "knative.dev/pkg/signals" + "knative.dev/pkg/webhook" + "knative.dev/pkg/webhook/certificates" + "knative.dev/pkg/webhook/configmaps" + "knative.dev/pkg/webhook/resourcesemantics" + "knative.dev/pkg/webhook/resourcesemantics/defaulting" + "knative.dev/pkg/webhook/resourcesemantics/validation" + + "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" +) + +var types = map[schema.GroupVersionKind]resourcesemantics.GenericCRD{ + // List the types to validate + v1alpha1.SchemeGroupVersion.WithKind("HorizonSource"): &v1alpha1.HorizonSource{}, +} + +var callbacks = map[schema.GroupVersionKind]validation.Callback{} + +const admissionWebhookName = "horizon-source-webhook" + +// NewDefaultingAdmissionController sets up mutating webhook. +func NewDefaultingAdmissionController(ctx context.Context, cmw configmap.Watcher) *controller.Impl { + return defaulting.NewAdmissionController(ctx, + + // Name of the resource webhook. + "defaulting.webhook.horizon.sources.tanzu.vmware.com", + + // The path on which to serve the webhook. + "/defaulting", + + // The resource to default. + types, + + // A function that infuses the context passed to Validate/SetDefaults with custom metadata. + func(ctx context.Context) context.Context { + // Here is where you would infuse the context with state + // (e.g. attach a store with configmap data) + return ctx + }, + + // Whether to disallow unknown fields. + true, + ) +} + +// NewValidationAdmissionController sets up validation webhook. +func NewValidationAdmissionController(ctx context.Context, cmw configmap.Watcher) *controller.Impl { + return validation.NewAdmissionController(ctx, + + // Name of the resource webhook. + "validation.webhook.horizon.sources.tanzu.vmware.com", + + // The path on which to serve the webhook. + "/resource-validation", + + // The resources to validate. + types, + + // A function that infuses the context passed to Validate/SetDefaults with custom metadata. + func(ctx context.Context) context.Context { + // Here is where you would infuse the context with state + // (e.g. attach a store with configmap data) + return ctx + }, + + // Whether to disallow unknown fields. + true, + + // Extra validating callbacks to be applied to resources. + callbacks, + ) +} + +// NewConfigValidationController sets up ConfigMap validation webhook. +func NewConfigValidationController(ctx context.Context, cmw configmap.Watcher) *controller.Impl { + return configmaps.NewAdmissionController(ctx, + + // Name of the configmap webhook. + "config.webhook.horizon.sources.tanzu.vmware.com", + + // The path on which to serve the webhook. + "/config-validation", + + // The configmaps to validate. + configmap.Constructors{ + logging.ConfigMapName(): logging.NewConfigFromConfigMap, + metrics.ConfigMapName(): metrics.NewObservabilityConfigFromConfigMap, + }, + ) +} + +func main() { + // Set up a signal context with our webhook options + ctx := webhook.WithOptions(signals.NewContext(), webhook.Options{ + ServiceName: admissionWebhookName, + Port: 8443, + SecretName: "webhook-certs", + }) + + sharedmain.WebhookMainWithContext(ctx, admissionWebhookName, + certificates.NewController, + NewDefaultingAdmissionController, + NewValidationAdmissionController, + NewConfigValidationController, + ) +} diff --git a/cmd/vsphere-adapter/main.go b/cmd/vsphere-adapter/main.go index dcef7522f..53ef3f677 100644 --- a/cmd/vsphere-adapter/main.go +++ b/cmd/vsphere-adapter/main.go @@ -20,9 +20,13 @@ import ( "github.com/vmware-tanzu/sources-for-knative/pkg/vsphere" ) +const ( + adapterName = "vsphere-source-adapter" +) + func main() { ctx := signals.NewContext() kc := kubernetes.NewForConfigOrDie(injection.ParseAndGetRESTConfigOrDie()) ctx = context.WithValue(ctx, kubeclient.Key{}, kc) - adapter.MainWithContext(ctx, "vspheresource", vsphere.NewEnvConfig, vsphere.NewAdapter) + adapter.MainWithContext(ctx, adapterName, vsphere.NewEnvConfig, vsphere.NewAdapter) } diff --git a/cmd/vsphere-controller/main.go b/cmd/vsphere-controller/main.go index 5939928f0..a63270e66 100644 --- a/cmd/vsphere-controller/main.go +++ b/cmd/vsphere-controller/main.go @@ -36,7 +36,9 @@ var types = map[schema.GroupVersionKind]resourcesemantics.GenericCRD{ v1alpha1.SchemeGroupVersion.WithKind("VSphereBinding"): &v1alpha1.VSphereBinding{}, } -const admissionWebhookName = "vsphere-source-webhook" +const ( + admissionWebhookName = "vsphere-source-webhook" +) func NewDefaultingAdmissionController(ctx context.Context, cmw configmap.Watcher) *controller.Impl { return defaulting.NewAdmissionController(ctx, diff --git a/config/200-horizon-serviceaccount.yaml b/config/200-horizon-serviceaccount.yaml new file mode 100644 index 000000000..9cc8d85a2 --- /dev/null +++ b/config/200-horizon-serviceaccount.yaml @@ -0,0 +1,20 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: horizon-source-controller + namespace: vmware-sources + labels: + sources.tanzu.vmware.com/release: devel + +--- + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: horizon-source-webhook + namespace: vmware-sources + labels: + sources.tanzu.vmware.com/release: devel diff --git a/config/vsphere/200-clusterrole-cm.yaml b/config/200-vsphere-clusterrole-cm.yaml similarity index 100% rename from config/vsphere/200-clusterrole-cm.yaml rename to config/200-vsphere-clusterrole-cm.yaml diff --git a/config/vsphere/200-clusterrole.yaml b/config/200-vsphere-clusterrole.yaml similarity index 98% rename from config/vsphere/200-clusterrole.yaml rename to config/200-vsphere-clusterrole.yaml index a2efdf688..6fac6b9d4 100644 --- a/config/vsphere/200-clusterrole.yaml +++ b/config/200-vsphere-clusterrole.yaml @@ -1,4 +1,4 @@ -# Copyright 2020 VMware, Inc. +# Copyright 2022 VMware, Inc. # SPDX-License-Identifier: Apache-2.0 kind: ClusterRole diff --git a/config/vsphere/200-podspecable-binding-clusterrole.yaml b/config/200-vsphere-podspecable-binding-clusterrole.yaml similarity index 97% rename from config/vsphere/200-podspecable-binding-clusterrole.yaml rename to config/200-vsphere-podspecable-binding-clusterrole.yaml index 5b034d365..490e03c71 100644 --- a/config/vsphere/200-podspecable-binding-clusterrole.yaml +++ b/config/200-vsphere-podspecable-binding-clusterrole.yaml @@ -1,4 +1,4 @@ -# Copyright 2020 VMware, Inc. +# Copyright 2022 VMware, Inc. # SPDX-License-Identifier: Apache-2.0 # Use this aggregated ClusterRole when you need readonly access to "podspecables diff --git a/config/vsphere/200-serviceaccount.yaml b/config/200-vsphere-serviceaccount.yaml similarity index 86% rename from config/vsphere/200-serviceaccount.yaml rename to config/200-vsphere-serviceaccount.yaml index e567a0005..c6eeb2965 100644 --- a/config/vsphere/200-serviceaccount.yaml +++ b/config/200-vsphere-serviceaccount.yaml @@ -1,4 +1,4 @@ -# Copyright 2020 VMware, Inc. +# Copyright 2022 VMware, Inc. # SPDX-License-Identifier: Apache-2.0 apiVersion: v1 diff --git a/config/201-horizon-clusterrole.yaml b/config/201-horizon-clusterrole.yaml new file mode 100644 index 000000000..864446bd4 --- /dev/null +++ b/config/201-horizon-clusterrole.yaml @@ -0,0 +1,96 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: horizon-source-controller + labels: + sources.tanzu.vmware.com/release: devel +rules: +- apiGroups: + - apps + resources: + - deployments + verbs: &everything + - get + - list + - watch + - create + - update + - patch + - delete + +- apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterroles + verbs: + - list + +- apiGroups: + - "" + resources: + - events + verbs: *everything + +- apiGroups: + - sources.tanzu.vmware.com + resources: + - horizonsources + verbs: *everything + +- apiGroups: + - sources.tanzu.vmware.com + resources: + - horizonsources/status + - horizonsources/finalizers + verbs: + - get + - update + - patch + +- apiGroups: + - "" + resources: + - configmaps + - secrets + verbs: + - get + - list + - watch + +# manage adapter SAs +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: *everything + + +# For Leader Election +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: *everything + +--- +# The role is needed for the aggregated role source-observer in knative-eventing to provide readonly access to "Sources". +# See https://github.com/knative/eventing/blob/master/config/200-source-observer-clusterrole.yaml. +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: horizon-source-observer + labels: + sources.tanzu.vmware.com/release: devel + duck.knative.dev/source: "true" +rules: + - apiGroups: + - "sources.eventing.knative.dev" + resources: + - "horizonsources" + verbs: + - get + - list + - watch diff --git a/config/vsphere/201-clusterrolebinding.yaml b/config/201-vsphere-clusterrolebinding.yaml similarity index 97% rename from config/vsphere/201-clusterrolebinding.yaml rename to config/201-vsphere-clusterrolebinding.yaml index 0a8a7215c..206636d4d 100644 --- a/config/vsphere/201-clusterrolebinding.yaml +++ b/config/201-vsphere-clusterrolebinding.yaml @@ -1,4 +1,4 @@ -# Copyright 2020 VMware, Inc. +# Copyright 2022 VMware, Inc. # SPDX-License-Identifier: Apache-2.0 apiVersion: rbac.authorization.k8s.io/v1 diff --git a/config/202-horizon-clusterrolebinding.yaml b/config/202-horizon-clusterrolebinding.yaml new file mode 100644 index 000000000..7697c169c --- /dev/null +++ b/config/202-horizon-clusterrolebinding.yaml @@ -0,0 +1,53 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: horizon-source-controller-rolebinding + labels: + sources.tanzu.vmware.com/release: devel +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: horizon-source-controller +subjects: +- kind: ServiceAccount + name: horizon-source-controller + namespace: vmware-sources + +--- + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: horizon-source-webhook-rolebinding + labels: + sources.tanzu.vmware.com/release: devel +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: horizon-source-webhook +subjects: + - kind: ServiceAccount + name: horizon-source-webhook + namespace: vmware-sources + +--- + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: horizon-source-controller-addressable-resolver + labels: + sources.tanzu.vmware.com/release: devel +subjects: +- kind: ServiceAccount + name: horizon-source-controller + namespace: vmware-sources +# An aggregated ClusterRole for all Addressable CRDs. +# Ref: https://knative.dev/eventing/blob/master/config/200-addressable-resolvers-clusterrole.yaml +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: addressable-resolver diff --git a/config/203-horizon-webhook-clusterrole.yaml b/config/203-horizon-webhook-clusterrole.yaml new file mode 100644 index 000000000..be46a545a --- /dev/null +++ b/config/203-horizon-webhook-clusterrole.yaml @@ -0,0 +1,111 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: horizon-source-webhook + labels: + sources.tanzu.vmware.com/release: devel +rules: + # Sources admin + - apiGroups: + - sources.knative.dev + resources: + - horizonsources + verbs: &everything + - get + - list + - watch + - create + - update + - patch + - delete + + # Sources finalizer + - apiGroups: + - sources.knative.dev + resources: + - horizonsources/finalizers + verbs: *everything + + # Source statuses update + - apiGroups: + - sources.knative.dev + resources: + - horizonsources/status + verbs: + - get + - update + - patch + + # Deployments admin + - apiGroups: + - apps + resources: + - deployments + verbs: *everything + + # Secrets read + - apiGroups: + - "" + resources: + - secrets + - services + verbs: + - get + - list + - watch + + # Namespace labelling for webhook + - apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch + - patch + + # Events admin + - apiGroups: + - "" + resources: + - events + - configmaps + verbs: *everything + + # EventTypes admin + - apiGroups: + - eventing.knative.dev + resources: + - eventtypes + verbs: *everything + + # For manipulating certs into secrets. + - apiGroups: + - "" + resources: + - "secrets" + verbs: + - "get" + - "create" + - "update" + - "list" + - "watch" + + # For actually registering our webhook. + - apiGroups: + - "admissionregistration.k8s.io" + resources: + - "mutatingwebhookconfigurations" + - "validatingwebhookconfigurations" + verbs: *everything + + # For Leader Election + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: *everything diff --git a/config/300-horizonsource.yaml b/config/300-horizonsource.yaml new file mode 100644 index 000000000..1e1f3fd3c --- /dev/null +++ b/config/300-horizonsource.yaml @@ -0,0 +1,56 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: horizonsources.sources.tanzu.vmware.com + labels: + sources.tanzu.vmware.com/release: devel + knative.dev/crd-install: "true" + duck.knative.dev/source: "true" + eventing.knative.dev/source: "true" + annotations: + registry.knative.dev/eventTypes: | + [ + { "type": "com.vmware.tanzu.sources" } + ] +spec: + group: sources.tanzu.vmware.com + names: + kind: HorizonSource + plural: horizonsources + singular: horizonsource + categories: + - all + - knative + - eventing + - horizon + - sources + shortNames: + - hs + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true + # TODO: use controller-gen from controller-tools to fill this in? + subresources: + status: {} + additionalPrinterColumns: + - name: Source + type: string + jsonPath: .spec.address + - name: Sink + type: string + jsonPath: .status.sinkUri + - name: Ready + type: string + jsonPath: ".status.conditions[?(@.type=='Ready')].status" + - name: Reason + type: string + jsonPath: ".status.conditions[?(@.type=='Ready')].reason" diff --git a/config/vsphere/300-vspherebinding.yaml b/config/300-vsphere-vspherebinding.yaml similarity index 97% rename from config/vsphere/300-vspherebinding.yaml rename to config/300-vsphere-vspherebinding.yaml index 927cddbf7..1615a2adb 100644 --- a/config/vsphere/300-vspherebinding.yaml +++ b/config/300-vsphere-vspherebinding.yaml @@ -1,4 +1,4 @@ -# Copyright 2020 VMware, Inc. +# Copyright 2022 VMware, Inc. # SPDX-License-Identifier: Apache-2.0 apiVersion: apiextensions.k8s.io/v1 diff --git a/config/vsphere/300-vspheresource.yaml b/config/300-vspheresource.yaml similarity index 89% rename from config/vsphere/300-vspheresource.yaml rename to config/300-vspheresource.yaml index c7e3a1a43..b7e7aeff1 100644 --- a/config/vsphere/300-vspheresource.yaml +++ b/config/300-vspheresource.yaml @@ -1,4 +1,4 @@ -# Copyright 2020 VMware, Inc. +# Copyright 2022 VMware, Inc. # SPDX-License-Identifier: Apache-2.0 apiVersion: apiextensions.k8s.io/v1 @@ -10,6 +10,11 @@ metadata: knative.dev/crd-install: "true" duck.knative.dev/source: "true" eventing.knative.dev/source: "true" + annotations: + registry.knative.dev/eventTypes: | + [ + { "type": "com.vmware.tanzu.sources" } + ] spec: group: sources.tanzu.vmware.com names: diff --git a/config/400-horizon-controller-service.yaml b/config/400-horizon-controller-service.yaml new file mode 100644 index 000000000..e7fb7f27b --- /dev/null +++ b/config/400-horizon-controller-service.yaml @@ -0,0 +1,16 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: Service +metadata: + labels: + sources.tanzu.vmware.com/release: devel + control-plane: horizon-source-controller-manager + name: horizon-source-controller-manager + namespace: vmware-sources +spec: + selector: + control-plane: horizon-source-controller-manager + ports: + - port: 443 diff --git a/config/400-horizon-webhook-service.yaml b/config/400-horizon-webhook-service.yaml new file mode 100644 index 000000000..7b94f9d09 --- /dev/null +++ b/config/400-horizon-webhook-service.yaml @@ -0,0 +1,18 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: Service +metadata: + labels: + sources.tanzu.vmware.com/release: devel + role: horizon-source-webhook + name: horizon-source-webhook + namespace: vmware-sources +spec: + ports: + - name: https-webhook + port: 443 + targetPort: 8443 + selector: + role: horizon-source-webhook diff --git a/config/vsphere/400-webhook-service.yaml b/config/400-vsphere-webhook-service.yaml similarity index 91% rename from config/vsphere/400-webhook-service.yaml rename to config/400-vsphere-webhook-service.yaml index df8f068c4..65486dbdf 100644 --- a/config/vsphere/400-webhook-service.yaml +++ b/config/400-vsphere-webhook-service.yaml @@ -1,4 +1,4 @@ -# Copyright 2020 VMware, Inc. +# Copyright 2022 VMware, Inc. # SPDX-License-Identifier: Apache-2.0 apiVersion: v1 diff --git a/config/500-horizon-controller.yaml b/config/500-horizon-controller.yaml new file mode 100644 index 000000000..04d193f8a --- /dev/null +++ b/config/500-horizon-controller.yaml @@ -0,0 +1,63 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: horizon-source-controller + namespace: vmware-sources + labels: + sources.tanzu.vmware.com/release: devel +spec: + replicas: 1 + selector: + matchLabels: + app: horizon-source-controller + template: + metadata: + labels: + app: horizon-source-controller + sources.tanzu.vmware.com/release: devel + control-plane: horizon-source-controller-manager + spec: + # To avoid node becoming SPOF, spread our replicas to different nodes. + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app: horizon-source-controller + topologyKey: kubernetes.io/hostname + weight: 100 + serviceAccountName: horizon-source-controller + containers: + - name: horizon-source-controller + terminationMessagePolicy: FallbackToLogsOnError + image: ko://github.com/vmware-tanzu/sources-for-knative/cmd/horizon-controller + resources: + limits: + cpu: 200m + memory: 200Mi + env: + - name: SYSTEM_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CONFIG_LOGGING_NAME + value: config-logging + - name: CONFIG_OBSERVABILITY_NAME + value: config-observability + - name: METRICS_DOMAIN + value: knative.dev/sources + - name: HORIZON_SOURCE_RA_IMAGE + value: ko://github.com/vmware-tanzu/sources-for-knative/cmd/horizon-adapter + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + securityContext: + allowPrivilegeEscalation: false + ports: + - name: metrics + containerPort: 9090 diff --git a/config/500-horizon-webhook-configuration.yaml b/config/500-horizon-webhook-configuration.yaml new file mode 100644 index 000000000..0f036e50c --- /dev/null +++ b/config/500-horizon-webhook-configuration.yaml @@ -0,0 +1,63 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: defaulting.webhook.horizon.sources.tanzu.vmware.com + labels: + sources.tanzu.vmware.com/release: devel +webhooks: + - admissionReviewVersions: ["v1", "v1beta1"] + clientConfig: + service: + name: horizon-source-webhook + namespace: vmware-sources + sideEffects: None + failurePolicy: Fail + name: defaulting.webhook.horizon.sources.tanzu.vmware.com +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validation.webhook.horizon.sources.tanzu.vmware.com + labels: + sources.tanzu.vmware.com/release: devel +webhooks: + - admissionReviewVersions: ["v1", "v1beta1"] + clientConfig: + service: + name: horizon-source-webhook + namespace: vmware-sources + sideEffects: None + failurePolicy: Fail + name: validation.webhook.horizon.sources.tanzu.vmware.com +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: config.webhook.horizon.sources.tanzu.vmware.com + labels: + sources.tanzu.vmware.com/release: devel +webhooks: + - admissionReviewVersions: ["v1", "v1beta1"] + clientConfig: + service: + name: horizon-source-webhook + namespace: vmware-sources + sideEffects: None + failurePolicy: Fail + name: config.webhook.horizon.sources.tanzu.vmware.com + namespaceSelector: + matchExpressions: + - key: sources.knative.dev/release + operator: Exists +--- +apiVersion: v1 +kind: Secret +metadata: + name: webhook-certs + namespace: vmware-sources + labels: + sources.tanzu.vmware.com/release: devel +# The data is populated at install time. diff --git a/config/500-horizon-webhook.yaml b/config/500-horizon-webhook.yaml new file mode 100644 index 000000000..71208b412 --- /dev/null +++ b/config/500-horizon-webhook.yaml @@ -0,0 +1,68 @@ +# Copyright 2022 VMware, Inc. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: horizon-source-webhook + namespace: vmware-sources + labels: + sources.tanzu.vmware.com/release: devel +spec: + replicas: 1 + selector: + matchLabels: &labels + app: horizon-source-webhook + role: horizon-source-webhook + template: + metadata: + labels: *labels + spec: + # To avoid node becoming SPOF, spread our replicas to different nodes. + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchLabels: + app: horizon-source-webhook + topologyKey: kubernetes.io/hostname + weight: 100 + + serviceAccountName: horizon-source-webhook + containers: + - name: horizon-source-webhook + terminationMessagePolicy: FallbackToLogsOnError + image: ko://github.com/vmware-tanzu/sources-for-knative/cmd/horizon-webhook + resources: + limits: + cpu: 200m + memory: 200Mi + env: + - name: SYSTEM_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CONFIG_LOGGING_NAME + value: config-logging + - name: METRICS_DOMAIN + value: knative.dev/eventing + - name: WEBHOOK_NAME + value: horizon-source-webhook + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + ports: + - containerPort: 9090 + name: metrics + readinessProbe: &probe + periodSeconds: 1 + httpGet: + scheme: HTTPS + port: 8443 + httpHeaders: + - name: k-kubelet-probe + value: "webhook" + livenessProbe: *probe + diff --git a/config/vsphere/500-webhook-configuration.yaml b/config/500-vsphere-webhook-configuration.yaml similarity index 98% rename from config/vsphere/500-webhook-configuration.yaml rename to config/500-vsphere-webhook-configuration.yaml index 9b47f1dc0..8b0cfb117 100644 --- a/config/vsphere/500-webhook-configuration.yaml +++ b/config/500-vsphere-webhook-configuration.yaml @@ -1,4 +1,4 @@ -# Copyright 2020 VMware, Inc. +# Copyright 2022 VMware, Inc. # SPDX-License-Identifier: Apache-2.0 apiVersion: admissionregistration.k8s.io/v1 diff --git a/config/vsphere/webhook.yaml b/config/500-vsphere-webhook.yaml similarity index 91% rename from config/vsphere/webhook.yaml rename to config/500-vsphere-webhook.yaml index 1e6759fa6..c8fa0ec71 100644 --- a/config/vsphere/webhook.yaml +++ b/config/500-vsphere-webhook.yaml @@ -1,4 +1,4 @@ -# Copyright 2020 VMware, Inc. +# Copyright 2022 VMware, Inc. # SPDX-License-Identifier: Apache-2.0 apiVersion: apps/v1 @@ -31,7 +31,6 @@ spec: app: vsphere-source-webhook topologyKey: kubernetes.io/hostname weight: 100 - serviceAccountName: vsphere-controller containers: - name: vsphere-source-webhook @@ -39,11 +38,6 @@ spec: # and substituted here. image: ko://github.com/vmware-tanzu/sources-for-knative/cmd/vsphere-controller resources: - # Request 2x what we saw running e2e - requests: - cpu: 20m - memory: 20Mi - # Limit to 10x the request (20x the observed peak during e2e) limits: cpu: 200m memory: 200Mi @@ -60,7 +54,6 @@ spec: value: tanzu.vmware.com/sources - name: WEBHOOK_NAME value: vsphere-source-webhook - readinessProbe: &probe # Increasing the failure threshold and adding an initial delay # avoids the situation where failing probes cause the vsphere-source-webhook to restart before it can diff --git a/config/config-logging.yaml b/config/config-logging.yaml index 5c839eef1..6be24d98f 100644 --- a/config/config-logging.yaml +++ b/config/config-logging.yaml @@ -35,6 +35,6 @@ data: # Log level overrides # For all components changes are be picked up immediately. - loglevel.controller: "info" - loglevel.webhook: "info" loglevel.vsphere-source-webhook: "info" + loglevel.horizon-source-webhook: "info" + loglevel.horizon-source-controller: "info" diff --git a/go.mod b/go.mod index ec0efe314..a656a55ae 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,11 @@ require ( ) require ( + github.com/benbjohnson/clock v1.1.0 + github.com/go-resty/resty/v2 v2.7.0 github.com/hashicorp/hcl v1.0.0 + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.7.0 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c gotest.tools/v3 v3.1.0 k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 @@ -48,7 +52,6 @@ require ( github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20211221011931-643d94fcab96 // indirect - github.com/benbjohnson/clock v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/blendle/zapdriver v1.3.1 // indirect @@ -102,7 +105,6 @@ require ( github.com/openzipkin/zipkin-go v0.3.0 // indirect github.com/pelletier/go-toml v1.9.4 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.11.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect @@ -117,7 +119,6 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.10.1 // indirect - github.com/stretchr/testify v1.7.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca // indirect go.opencensus.io v0.23.0 // indirect diff --git a/go.sum b/go.sum index a28cb02aa..f923a40be 100644 --- a/go.sum +++ b/go.sum @@ -245,6 +245,7 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0Bsq github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/butuzov/ireturn v0.1.1/go.mod h1:Wh6Zl3IMtTpaIKbmwzqi6olnM9ptYQxxVacMsOEFPoc= github.com/c2h5oh/datasize v0.0.0-20171227191756-4eba002a5eae/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= +github.com/c2h5oh/datasize v0.0.0-20171227191756-4eba002a5eae/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= @@ -265,8 +266,11 @@ github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d8 github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= github.com/chrismellard/docker-credential-acr-env v0.0.0-20220119192733-fe33c00cee21/go.mod h1:Zlre/PVxuSI9y6/UV4NwGixQ48RHQDSPiUkofr6rbMU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg= github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc= github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs= @@ -574,6 +578,8 @@ github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/ github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-redis/redis v6.15.8+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= +github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= @@ -699,6 +705,8 @@ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8 github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0= github.com/google/go-containerregistry v0.8.1-0.20220414133640-f1b729141d33/go.mod h1:eTLvLZaEe2FoQsb25t7BLxQQryyrwHTzFfwxN87mhAw= github.com/google/go-containerregistry v0.8.1-0.20220414143355-892d7a808387 h1:GWICy4b02s8EA1M9H5krRQ48BKpIHO5LtBBm2BQLhx0= +github.com/google/go-containerregistry v0.8.1-0.20220414143355-892d7a808387 h1:GWICy4b02s8EA1M9H5krRQ48BKpIHO5LtBBm2BQLhx0= +github.com/google/go-containerregistry v0.8.1-0.20220414143355-892d7a808387/go.mod h1:eTLvLZaEe2FoQsb25t7BLxQQryyrwHTzFfwxN87mhAw= github.com/google/go-containerregistry v0.8.1-0.20220414143355-892d7a808387/go.mod h1:eTLvLZaEe2FoQsb25t7BLxQQryyrwHTzFfwxN87mhAw= github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20220414154538-570ba6c88a50/go.mod h1:m7mMYMlUraMy65yWp4AXkMgousS5LFPYcvI19yjz6W0= github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20220414143355-892d7a808387/go.mod h1:QOryQrrP9Uq/1w9F7WOWWhK2/gHXg7F0i3J/hPG6yQA= @@ -1256,12 +1264,14 @@ github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiB github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYIR88KRMEuODE= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= +github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sanposhiho/wastedassign/v2 v2.0.6/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= github.com/securego/gosec/v2 v2.9.1/go.mod h1:oDcDLcatOJxkCGaCaq8lua1jTnYf6Sou4wdiJ1n4iHc= @@ -1506,6 +1516,7 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/ go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= @@ -1650,6 +1661,7 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -2265,6 +2277,7 @@ k8s.io/component-base v0.20.4/go.mod h1:t4p9EdiagbVCJKrQ1RsA5/V4rFQNDfRlevJajlGw k8s.io/component-base v0.20.6/go.mod h1:6f1MPBAeI+mvuts3sIdtpjljHWBQ2cIy38oBIWMYnrM= k8s.io/component-base v0.22.5/go.mod h1:VK3I+TjuF9eaa+Ln67dKxhGar5ynVbwnGrUiNF4MqCI= k8s.io/component-base v0.23.4/go.mod h1:8o3Gg8i2vnUXGPOwciiYlkSaZT+p+7gA9Scoz8y4W4E= +k8s.io/component-base v0.23.4/go.mod h1:8o3Gg8i2vnUXGPOwciiYlkSaZT+p+7gA9Scoz8y4W4E= k8s.io/component-base v0.23.8/go.mod h1:rCj6EeaYLsNneVoFuSPL/AlEWmomc39j9M9i4NpR8r0= k8s.io/cri-api v0.17.3/go.mod h1:X1sbHmuXhwaHs9xxYffLqJogVsnI+f6cPRcgPel7ywM= k8s.io/cri-api v0.20.1/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI= @@ -2340,6 +2353,7 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.14/go.mod h1:LEScyz sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.15/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.22/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.27/go.mod h1:tq2nT0Kx7W+/f2JVE+zxYtUhdjuELJkVpNz+x/QN5R4= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.27/go.mod h1:tq2nT0Kx7W+/f2JVE+zxYtUhdjuELJkVpNz+x/QN5R4= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.30/go.mod h1:fEO7lRTdivWO2qYVCVG7dEADOMo/MLDCVr8So2g88Uw= sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6/go.mod h1:p4QtZmO4uMYipTQNzagwnNoseA6OxSUutVw05NhYDRs= sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 h1:kDi4JBNAsJWfz1aEXhO8Jg87JJaPNLh5tIzYHgStQ9Y= diff --git a/pkg/apis/sources/v1alpha1/horizonsource_defaults.go b/pkg/apis/sources/v1alpha1/horizonsource_defaults.go new file mode 100644 index 000000000..0a31d6bce --- /dev/null +++ b/pkg/apis/sources/v1alpha1/horizonsource_defaults.go @@ -0,0 +1,23 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package v1alpha1 + +import ( + "context" + + "knative.dev/pkg/apis" +) + +// SetDefaults mutates HorizonSource. +func (hs *HorizonSource) SetDefaults(ctx context.Context) { + if hs != nil && hs.Spec.ServiceAccountName == "" { + hs.Spec.ServiceAccountName = "default" + } + + // call SetDefaults against duckv1.Destination with a context of ObjectMeta of HorizonSource. + withNS := apis.WithinParent(ctx, hs.ObjectMeta) + hs.Spec.Sink.SetDefaults(withNS) +} diff --git a/pkg/apis/sources/v1alpha1/horizonsource_defaults_test.go b/pkg/apis/sources/v1alpha1/horizonsource_defaults_test.go new file mode 100644 index 000000000..c10ef4e80 --- /dev/null +++ b/pkg/apis/sources/v1alpha1/horizonsource_defaults_test.go @@ -0,0 +1,70 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package v1alpha1 + +import ( + "context" + "testing" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + duckv1 "knative.dev/pkg/apis/duck/v1" + + "github.com/google/go-cmp/cmp" +) + +func TestHorizonSourceDefaults(t *testing.T) { + testCases := map[string]struct { + initial HorizonSource + expected HorizonSource + }{ + "nil spec": { + initial: HorizonSource{}, + expected: HorizonSource{ + Spec: HorizonSourceSpec{ + ServiceAccountName: "default", + }, + }, + }, + "no namespace in sink reference": { + initial: HorizonSource{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "parent", + }, + Spec: HorizonSourceSpec{ + ServiceAccountName: "default", + SourceSpec: duckv1.SourceSpec{ + Sink: duckv1.Destination{ + Ref: &duckv1.KReference{}, + }, + }, + }, + }, + expected: HorizonSource{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "parent", + }, + Spec: HorizonSourceSpec{ + ServiceAccountName: "default", + SourceSpec: duckv1.SourceSpec{ + Sink: duckv1.Destination{ + Ref: &duckv1.KReference{ + Namespace: "parent", + }, + }, + }, + }, + }, + }, + } + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + tc.initial.SetDefaults(context.TODO()) + if diff := cmp.Diff(tc.expected, tc.initial); diff != "" { + t.Fatalf("Unexpected defaults (-want, +got): %s", diff) + } + }) + } +} diff --git a/pkg/apis/sources/v1alpha1/horizonsource_lifecycle.go b/pkg/apis/sources/v1alpha1/horizonsource_lifecycle.go new file mode 100644 index 000000000..cb0cdd60f --- /dev/null +++ b/pkg/apis/sources/v1alpha1/horizonsource_lifecycle.go @@ -0,0 +1,76 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +//nolint:stylecheck +package v1alpha1 + +import ( + appsv1 "k8s.io/api/apps/v1" + "knative.dev/eventing/pkg/apis/duck" + "knative.dev/pkg/apis" +) + +const ( + // HorizonSourceConditionReady has status True when the HorizonSource is ready to send events. + HorizonSourceConditionReady = apis.ConditionReady + + // HorizonSourceConditionSinkProvided has status True when the HorizonSource has been configured with a sink target. + HorizonSourceConditionSinkProvided apis.ConditionType = "SinkProvided" + + // HorizonSourceConditionDeployed has status True when the HorizonSource has had it's adapter deployment created. + HorizonSourceConditionDeployed apis.ConditionType = "Deployed" +) + +var HorizonSourceCondSet = apis.NewLivingConditionSet( + HorizonSourceConditionSinkProvided, + HorizonSourceConditionDeployed, +) + +// GetCondition returns the condition currently associated with the given type, or nil. +func (hss *HorizonSourceStatus) GetCondition(t apis.ConditionType) *apis.Condition { + return HorizonSourceCondSet.Manage(hss).GetCondition(t) +} + +// InitializeConditions sets relevant unset conditions to Unknown state. +func (hss *HorizonSourceStatus) InitializeConditions() { + HorizonSourceCondSet.Manage(hss).InitializeConditions() +} + +// GetConditionSet returns HorizonSource ConditionSet. +func (hs *HorizonSource) GetConditionSet() apis.ConditionSet { + return HorizonSourceCondSet +} + +// MarkSink sets the condition that the source has a sink configured. +func (hss *HorizonSourceStatus) MarkSink(uri *apis.URL) { + hss.SinkURI = uri + if len(uri.String()) > 0 { + HorizonSourceCondSet.Manage(hss).MarkTrue(HorizonSourceConditionSinkProvided) + } else { + HorizonSourceCondSet.Manage(hss).MarkUnknown(HorizonSourceConditionSinkProvided, "SinkEmpty", "Sink has resolved to empty.") + } +} + +// MarkNoSink sets the condition that the source does not have a sink configured. +func (hss *HorizonSourceStatus) MarkNoSink(reason, messageFormat string, messageA ...interface{}) { + HorizonSourceCondSet.Manage(hss).MarkFalse(HorizonSourceConditionSinkProvided, reason, messageFormat, messageA...) +} + +// PropagateDeploymentAvailability uses the availability of the provided Deployment to determine if +// HorizonSourceConditionDeployed should be marked as true or false. +func (hss *HorizonSourceStatus) PropagateDeploymentAvailability(d *appsv1.Deployment) { + if duck.DeploymentIsAvailable(&d.Status, false) { + HorizonSourceCondSet.Manage(hss).MarkTrue(HorizonSourceConditionDeployed) + } else { + // I don't know how to propagate the status well, so just give the name of the Deployment + // for now. + HorizonSourceCondSet.Manage(hss).MarkFalse(HorizonSourceConditionDeployed, "DeploymentUnavailable", "The Deployment '%s' is unavailable.", d.Name) + } +} + +// IsReady returns true if the resource is ready overall. +func (hss *HorizonSourceStatus) IsReady() bool { + return HorizonSourceCondSet.Manage(hss).IsHappy() +} diff --git a/pkg/apis/sources/v1alpha1/horizonsource_lifecycle_test.go b/pkg/apis/sources/v1alpha1/horizonsource_lifecycle_test.go new file mode 100644 index 000000000..06d7fd8de --- /dev/null +++ b/pkg/apis/sources/v1alpha1/horizonsource_lifecycle_test.go @@ -0,0 +1,132 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package v1alpha1 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "knative.dev/pkg/apis" + "knative.dev/pkg/apis/duck" + duckv1 "knative.dev/pkg/apis/duck/v1" +) + +var availableDeployment = &appsv1.Deployment{ + Status: appsv1.DeploymentStatus{ + Conditions: []appsv1.DeploymentCondition{ + { + Type: appsv1.DeploymentAvailable, + Status: corev1.ConditionTrue, + }, + }, + }, +} + +var _ = duck.VerifyType(&HorizonSource{}, &duckv1.Conditions{}) + +func TestRabbitmqSourceStatusGetCondition(t *testing.T) { + tests := []struct { + name string + s *HorizonSourceStatus + condQuery apis.ConditionType + want *apis.Condition + }{ + { + name: "uninitialized", + s: &HorizonSourceStatus{}, + condQuery: HorizonSourceConditionReady, + want: nil, + }, + { + name: "initialized", + s: func() *HorizonSourceStatus { + s := &HorizonSourceStatus{} + s.InitializeConditions() + return s + }(), + condQuery: HorizonSourceConditionReady, + want: &apis.Condition{ + Type: HorizonSourceConditionReady, + Status: corev1.ConditionUnknown, + }, + }, + { + name: "mark deployed", + s: func() *HorizonSourceStatus { + s := &HorizonSourceStatus{} + s.InitializeConditions() + s.PropagateDeploymentAvailability(availableDeployment) + return s + }(), + condQuery: HorizonSourceConditionReady, + want: &apis.Condition{ + Type: HorizonSourceConditionReady, + Status: corev1.ConditionUnknown, + }, + }, + { + name: "mark sink", + s: func() *HorizonSourceStatus { + s := &HorizonSourceStatus{} + s.InitializeConditions() + s.MarkSink(apis.HTTP("uri://example")) + return s + }(), + condQuery: HorizonSourceConditionReady, + want: &apis.Condition{ + Type: HorizonSourceConditionReady, + Status: corev1.ConditionUnknown, + }, + }, + { + name: "mark sink and adapter deployed", + s: func() *HorizonSourceStatus { + s := &HorizonSourceStatus{} + s.InitializeConditions() + s.MarkSink(apis.HTTP("uri://example")) + s.PropagateDeploymentAvailability(availableDeployment) + return s + }(), + condQuery: HorizonSourceConditionReady, + want: &apis.Condition{ + Type: HorizonSourceConditionReady, + Status: corev1.ConditionTrue, + }, + }, + { + name: "mark sink and adapter deployed then no sink", + s: func() *HorizonSourceStatus { + s := &HorizonSourceStatus{} + s.InitializeConditions() + s.MarkSink(apis.HTTP("uri://example")) + s.PropagateDeploymentAvailability(availableDeployment) + s.MarkNoSink("Testing", "hi%s", "") + return s + }(), + condQuery: HorizonSourceConditionReady, + want: &apis.Condition{ + Type: HorizonSourceConditionReady, + Status: corev1.ConditionFalse, + Reason: "Testing", + Message: "hi", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := test.s.GetCondition(test.condQuery) + ignoreTime := cmpopts.IgnoreFields(apis.Condition{}, + "LastTransitionTime", "Severity") + if diff := cmp.Diff(test.want, got, ignoreTime); diff != "" { + t.Errorf("unexpected condition (-want, +got) = %v", diff) + } + }) + } +} diff --git a/pkg/apis/sources/v1alpha1/horizonsource_types.go b/pkg/apis/sources/v1alpha1/horizonsource_types.go new file mode 100644 index 000000000..14054b94f --- /dev/null +++ b/pkg/apis/sources/v1alpha1/horizonsource_types.go @@ -0,0 +1,116 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "knative.dev/pkg/apis" + "knative.dev/pkg/apis/duck" + duckv1 "knative.dev/pkg/apis/duck/v1" + "knative.dev/pkg/kmeta" + "knative.dev/pkg/webhook/resourcesemantics" +) + +// +genclient +// +genreconciler +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type HorizonSource struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec holds the desired state of the HorizonSource (from the client). + Spec HorizonSourceSpec `json:"spec"` + + // Status communicates the observed state of the HorizonSource (from the controller). + // +optional + Status HorizonSourceStatus `json:"status,omitempty"` +} + +// GetGroupVersionKind returns the GroupVersionKind. +func (*HorizonSource) GetGroupVersionKind() schema.GroupVersionKind { + return SchemeGroupVersion.WithKind("HorizonSource") +} + +var ( + // Check that HorizonSource can be validated and defaulted. + _ apis.Validatable = (*HorizonSource)(nil) + _ apis.Defaultable = (*HorizonSource)(nil) + // Check that we can create OwnerReferences to a HorizonSource. + _ kmeta.OwnerRefable = (*HorizonSource)(nil) + // Check that HorizonSource is a runtime.Object. + _ runtime.Object = (*HorizonSource)(nil) + // Check that HorizonSource satisfies resourcesemantics.GenericCRD. + _ resourcesemantics.GenericCRD = (*HorizonSource)(nil) + // Check that HorizonSource implements the Conditions duck type. + _ = duck.VerifyType(&HorizonSource{}, &duckv1.Conditions{}) + // Check that the type conforms to the duck Knative Resource shape. + _ duckv1.KRShaped = (*HorizonSource)(nil) +) + +// HorizonAuthSpec is the information used to authenticate with a Horizon API +type HorizonAuthSpec struct { + // Address contains the URL of the vSphere API. + Address apis.URL `json:"address"` + + // SkipTLSVerify specifies whether the client should skip TLS verification when + // talking to the vsphere address. + SkipTLSVerify bool `json:"skipTLSVerify,omitempty"` + + // SecretRef is a reference to a Kubernetes secret which contains keys for + // "domain", "username" and "password", which will be used to authenticate with + // the Horizon API at "address". + SecretRef corev1.LocalObjectReference `json:"secretRef"` +} + +// HorizonSourceSpec holds the desired state of the HorizonSource (from the client). +type HorizonSourceSpec struct { + // inherits duck/v1 SourceSpec, which currently provides: + // * Sink - a reference to an object that will resolve to a domain name or + // a URI directly to use as the sink. + // * CloudEventOverrides - defines overrides to control the output format + // and modifications of the event sent to the sink. + duckv1.SourceSpec `json:",inline"` + + // ServiceAccountName holds the name of the Kubernetes service account + // as which the underlying K8s resources should be run. If unspecified + // this will default to the "default" service account for the namespace + // in which the HorizonSource exists. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` + + HorizonAuthSpec `json:",inline"` +} + +// HorizonSourceStatus communicates the observed state of the HorizonSource (from the controller). +type HorizonSourceStatus struct { + // inherits duck/v1 SourceStatus, which currently provides: + // * ObservedGeneration - the 'Generation' of the Service that was last + // processed by the controller. + // * Conditions - the latest available observations of a resource's current + // state. + // * SinkURI - the current active sink URI that has been configured for the + // Source. + duckv1.SourceStatus `json:",inline"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// HorizonSourceList is a list of HorizonSource resources +type HorizonSourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []HorizonSource `json:"items"` +} + +// GetStatus retrieves the status of the resource. Implements the KRShaped interface. +func (hs *HorizonSource) GetStatus() *duckv1.Status { + return &hs.Status.Status +} diff --git a/pkg/apis/sources/v1alpha1/horizonsource_types_test.go b/pkg/apis/sources/v1alpha1/horizonsource_types_test.go new file mode 100644 index 000000000..b73fac593 --- /dev/null +++ b/pkg/apis/sources/v1alpha1/horizonsource_types_test.go @@ -0,0 +1,19 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package v1alpha1 + +import ( + "testing" +) + +func TestHorizonSource_GetGroupVersionKind(t *testing.T) { + src := HorizonSource{} + gvk := src.GetGroupVersionKind() + + if gvk.Kind != "HorizonSource" { + t.Errorf("Should be 'HorizonSource'.") + } +} diff --git a/pkg/apis/sources/v1alpha1/horizonsource_validation.go b/pkg/apis/sources/v1alpha1/horizonsource_validation.go new file mode 100644 index 000000000..66191965c --- /dev/null +++ b/pkg/apis/sources/v1alpha1/horizonsource_validation.go @@ -0,0 +1,65 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package v1alpha1 + +import ( + "context" + + "knative.dev/pkg/apis" + "knative.dev/pkg/kmp" +) + +// Validate validates HorizonSource. +func (src *HorizonSource) Validate(ctx context.Context) *apis.FieldError { + var errs *apis.FieldError + + if apis.IsInUpdate(ctx) { + original := apis.GetBaseline(ctx).(*HorizonSource) + + // all fields immutable + if diff, err := kmp.ShortDiff(original.Spec, src.Spec); err != nil { + return &apis.FieldError{ + Message: "Failed to diff HorizonSource", + Paths: []string{"spec"}, + Details: err.Error(), + } + } else if diff != "" { + return &apis.FieldError{ + Message: "Immutable fields changed (-old +new)", + Paths: []string{"spec"}, + Details: diff, + } + } + } + + errs = errs.Also(src.Spec.Validate(ctx).ViaField("spec")) + return errs +} + +// Validate validates HorizonSourceSpec. +func (spec *HorizonSourceSpec) Validate(ctx context.Context) *apis.FieldError { + var errs *apis.FieldError + + errs = spec.Sink.Validate(ctx).ViaField("sink"). + Also(spec.HorizonAuthSpec.Validate(ctx)) + + if spec.ServiceAccountName == "" { + errs = errs.Also(apis.ErrMissingField("serviceAccountName")) + } + + return errs +} + +// Validate implements apis.Validatable +func (auth *HorizonAuthSpec) Validate(ctx context.Context) (err *apis.FieldError) { + if auth.Address.Host == "" { + err = err.Also(apis.ErrMissingField("address.host")) + } + if auth.SecretRef.Name == "" { + err = err.Also(apis.ErrMissingField("secretRef.name")) + } + return err +} diff --git a/pkg/apis/sources/v1alpha1/horizonsource_validation_test.go b/pkg/apis/sources/v1alpha1/horizonsource_validation_test.go new file mode 100644 index 000000000..ecaca23d7 --- /dev/null +++ b/pkg/apis/sources/v1alpha1/horizonsource_validation_test.go @@ -0,0 +1,308 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package v1alpha1 + +import ( + "context" + "net/url" + "testing" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + duckv1 "knative.dev/pkg/apis/duck/v1" + "knative.dev/pkg/webhook/resourcesemantics" + + "knative.dev/pkg/apis" +) + +var ( + source, _ = apis.ParseURL("https://horizon.api.dev") + + fullSpec HorizonSourceSpec = HorizonSourceSpec{ + SourceSpec: duckv1.SourceSpec{ + Sink: duckv1.Destination{ + Ref: &duckv1.KReference{ + APIVersion: "v1", + Kind: "Broker", + Namespace: "default", + Name: "default", + }, + }, + }, + ServiceAccountName: "default", + HorizonAuthSpec: HorizonAuthSpec{ + Address: *source, + SkipTLSVerify: false, + SecretRef: corev1.LocalObjectReference{ + Name: "horizon-secret", + }, + }, + } +) + +func TestHorizonSourceImmutableFields(t *testing.T) { + testCases := map[string]struct { + orig *HorizonSourceSpec + updated HorizonSourceSpec + allowed bool + }{ + "nil orig": { + updated: fullSpec, + allowed: true, + }, + "Sink.Namespace changed": { + orig: &fullSpec, + updated: HorizonSourceSpec{ + SourceSpec: duckv1.SourceSpec{ + Sink: duckv1.Destination{ + Ref: &duckv1.KReference{ + APIVersion: fullSpec.Sink.Ref.APIVersion, + Kind: fullSpec.Sink.Ref.Kind, + Namespace: "changed", + Name: fullSpec.Sink.Ref.Name, + }, + }, + }, + ServiceAccountName: fullSpec.ServiceAccountName, + HorizonAuthSpec: fullSpec.HorizonAuthSpec, + }, + allowed: false, + }, + "Sink.Name changed": { + orig: &fullSpec, + updated: HorizonSourceSpec{ + SourceSpec: duckv1.SourceSpec{ + Sink: duckv1.Destination{ + Ref: &duckv1.KReference{ + APIVersion: fullSpec.Sink.Ref.APIVersion, + Kind: fullSpec.Sink.Ref.Kind, + Namespace: fullSpec.Sink.Ref.Namespace, + Name: "changed", + }, + }, + }, + ServiceAccountName: fullSpec.ServiceAccountName, + HorizonAuthSpec: fullSpec.HorizonAuthSpec, + }, + allowed: false, + }, + "Sink.ApiVersion changed": { + orig: &fullSpec, + updated: HorizonSourceSpec{ + SourceSpec: duckv1.SourceSpec{ + Sink: duckv1.Destination{ + Ref: &duckv1.KReference{ + APIVersion: "v1alpha1", + Kind: fullSpec.Sink.Ref.Kind, + Namespace: fullSpec.Sink.Ref.Namespace, + Name: fullSpec.Sink.Ref.Name, + }, + }, + }, + ServiceAccountName: fullSpec.ServiceAccountName, + HorizonAuthSpec: fullSpec.HorizonAuthSpec, + }, + allowed: false, + }, + "ServiceAccount changed": { + orig: &fullSpec, + updated: HorizonSourceSpec{ + SourceSpec: fullSpec.SourceSpec, + ServiceAccountName: "changed", + HorizonAuthSpec: fullSpec.HorizonAuthSpec, + }, + allowed: false, + }, + "Auth.Address changed": { + orig: &fullSpec, + updated: HorizonSourceSpec{ + SourceSpec: fullSpec.SourceSpec, + ServiceAccountName: fullSpec.ServiceAccountName, + HorizonAuthSpec: HorizonAuthSpec{ + Address: apis.URL{ + Scheme: "http", + Host: "changed.example.com", + }, + SkipTLSVerify: fullSpec.SkipTLSVerify, + SecretRef: fullSpec.SecretRef, + }, + }, + allowed: false, + }, + "Auth.SkipTLSVerify changed": { + orig: &fullSpec, + updated: HorizonSourceSpec{ + SourceSpec: fullSpec.SourceSpec, + ServiceAccountName: fullSpec.ServiceAccountName, + HorizonAuthSpec: HorizonAuthSpec{ + Address: fullSpec.Address, + SkipTLSVerify: true, + SecretRef: fullSpec.SecretRef, + }, + }, + allowed: false, + }, + "Auth.SecretRef changed": { + orig: &fullSpec, + updated: HorizonSourceSpec{ + SourceSpec: fullSpec.SourceSpec, + ServiceAccountName: fullSpec.ServiceAccountName, + HorizonAuthSpec: HorizonAuthSpec{ + Address: fullSpec.Address, + SkipTLSVerify: fullSpec.SkipTLSVerify, + SecretRef: corev1.LocalObjectReference{Name: "changed"}, + }, + }, + allowed: false, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ctx := context.TODO() + if tc.orig != nil { + orig := &HorizonSource{ + Spec: *tc.orig, + } + + ctx = apis.WithinUpdate(ctx, orig) + } + updated := &HorizonSource{ + Spec: tc.updated, + } + err := updated.Validate(ctx) + if tc.allowed != (err == nil) { + t.Fatalf("Unexpected immutable field check. Expected %v. Actual %v", tc.allowed, err) + } + }) + } +} + +func TestHorizonSourceValidation(t *testing.T) { + testCases := map[string]struct { + cr resourcesemantics.GenericCRD + want *apis.FieldError + }{ + "invalid nil spec": { + cr: &HorizonSource{ + Spec: HorizonSourceSpec{}, + }, + want: func() *apis.FieldError { + var errs *apis.FieldError + + feSink := apis.ErrGeneric("expected at least one, got none", "ref", "uri") + feSink = feSink.ViaField("sink").ViaField("spec") + errs = errs.Also(feSink) + + feAddress := apis.ErrMissingField("address.host") + feAddress = feAddress.ViaField("spec") + errs = errs.Also(feAddress) + + secretRef := apis.ErrMissingField("secretRef.name") + secretRef = secretRef.ViaField("spec") + errs = errs.Also(secretRef) + + feServiceAccountName := apis.ErrMissingField("serviceAccountName") + feServiceAccountName = feServiceAccountName.ViaField("spec") + errs = errs.Also(feServiceAccountName) + + return errs + }(), + }, + "secret missing": { + cr: &HorizonSource{ + Spec: HorizonSourceSpec{ + SourceSpec: duckv1.SourceSpec{ + Sink: newDestination(), + }, + ServiceAccountName: "default", + HorizonAuthSpec: HorizonAuthSpec{ + Address: newHorizonAddress(), + }, + }, + }, + want: func() *apis.FieldError { + var errs *apis.FieldError + + secretRef := apis.ErrMissingField("secretRef.name") + secretRef = secretRef.ViaField("spec") + errs = errs.Also(secretRef) + + return errs + }(), + }, + "horizon source address missing": { + cr: &HorizonSource{ + Spec: HorizonSourceSpec{ + SourceSpec: duckv1.SourceSpec{ + Sink: newDestination(), + }, + ServiceAccountName: "default", + HorizonAuthSpec: HorizonAuthSpec{ + SecretRef: newSecretRef(), + }, + }, + }, + want: func() *apis.FieldError { + var errs *apis.FieldError + + secretRef := apis.ErrMissingField("address.host") + secretRef = secretRef.ViaField("spec") + errs = errs.Also(secretRef) + + return errs + }(), + }, + "valid spec": { + cr: &HorizonSource{ + Spec: HorizonSourceSpec{ + SourceSpec: duckv1.SourceSpec{ + Sink: newDestination(), + }, + ServiceAccountName: "default", + HorizonAuthSpec: HorizonAuthSpec{ + Address: newHorizonAddress(), + SecretRef: newSecretRef(), + }, + }, + }, + want: func() *apis.FieldError { + return nil + }(), + }, + } + + for n, test := range testCases { + t.Run(n, func(t *testing.T) { + got := test.cr.Validate(context.Background()) + if diff := cmp.Diff(test.want.Error(), got.Error()); diff != "" { + t.Errorf("%s: validate (-want, +got) = %v", n, diff) + } + }) + } +} + +func newSecretRef() corev1.LocalObjectReference { + return corev1.LocalObjectReference{ + Name: "horizon-creds", + } +} + +func newDestination() duckv1.Destination { + return duckv1.Destination{ + Ref: &duckv1.KReference{ + Kind: "Deployment", + Namespace: "default", + Name: "receiver", + APIVersion: "v1", + }, + } +} + +func newHorizonAddress() apis.URL { + u, _ := url.Parse("http://api.horizon.corp.local") + return apis.URL(*u) +} diff --git a/pkg/apis/sources/v1alpha1/register.go b/pkg/apis/sources/v1alpha1/register.go index 311f43092..b86dd11bd 100644 --- a/pkg/apis/sources/v1alpha1/register.go +++ b/pkg/apis/sources/v1alpha1/register.go @@ -38,6 +38,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &VSphereSourceList{}, &VSphereBinding{}, &VSphereBindingList{}, + &HorizonSource{}, + &HorizonSourceList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/pkg/apis/sources/v1alpha1/register_test.go b/pkg/apis/sources/v1alpha1/register_test.go index f5ce89970..aa673b49d 100644 --- a/pkg/apis/sources/v1alpha1/register_test.go +++ b/pkg/apis/sources/v1alpha1/register_test.go @@ -12,18 +12,26 @@ import ( ) func TestRegisterHelpers(t *testing.T) { - if got, want := Kind("Foo"), "Foo.sources.tanzu.vmware.com"; got.String() != want { - t.Errorf("Kind(Foo) = %v, want %v", got.String(), want) + if got, want := Kind("VsphereSource"), "VsphereSource.sources.tanzu.vmware.com"; got.String() != want { + t.Errorf("Kind(VsphereSource) = %v, want %v", got.String(), want) } - if got, want := Resource("Foo"), "Foo.sources.tanzu.vmware.com"; got.String() != want { - t.Errorf("Resource(Foo) = %v, want %v", got.String(), want) + if got, want := Resource("VsphereSource"), "VsphereSource.sources.tanzu.vmware.com"; got.String() != want { + t.Errorf("Resource(VsphereSource) = %v, want %v", got.String(), want) } if got, want := SchemeGroupVersion.String(), "sources.tanzu.vmware.com/v1alpha1"; got != want { t.Errorf("SchemeGroupVersion() = %v, want %v", got, want) } + if got, want := Kind("HorizonSource"), "HorizonSource.sources.tanzu.vmware.com"; got.String() != want { + t.Errorf("Kind(HorizonSource) = %v, want %v", got.String(), want) + } + + if got, want := Resource("HorizonSource"), "HorizonSource.sources.tanzu.vmware.com"; got.String() != want { + t.Errorf("Resource(HorizonSource) = %v, want %v", got.String(), want) + } + scheme := runtime.NewScheme() if err := addKnownTypes(scheme); err != nil { t.Errorf("addKnownTypes() = %v", err) diff --git a/pkg/apis/sources/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/sources/v1alpha1/zz_generated.deepcopy.go index de63d9e10..bc39b40e8 100644 --- a/pkg/apis/sources/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/sources/v1alpha1/zz_generated.deepcopy.go @@ -14,6 +14,120 @@ 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 *HorizonAuthSpec) DeepCopyInto(out *HorizonAuthSpec) { + *out = *in + in.Address.DeepCopyInto(&out.Address) + out.SecretRef = in.SecretRef + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HorizonAuthSpec. +func (in *HorizonAuthSpec) DeepCopy() *HorizonAuthSpec { + if in == nil { + return nil + } + out := new(HorizonAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HorizonSource) DeepCopyInto(out *HorizonSource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HorizonSource. +func (in *HorizonSource) DeepCopy() *HorizonSource { + if in == nil { + return nil + } + out := new(HorizonSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HorizonSource) 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 *HorizonSourceList) DeepCopyInto(out *HorizonSourceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]HorizonSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HorizonSourceList. +func (in *HorizonSourceList) DeepCopy() *HorizonSourceList { + if in == nil { + return nil + } + out := new(HorizonSourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HorizonSourceList) 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 *HorizonSourceSpec) DeepCopyInto(out *HorizonSourceSpec) { + *out = *in + in.SourceSpec.DeepCopyInto(&out.SourceSpec) + in.HorizonAuthSpec.DeepCopyInto(&out.HorizonAuthSpec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HorizonSourceSpec. +func (in *HorizonSourceSpec) DeepCopy() *HorizonSourceSpec { + if in == nil { + return nil + } + out := new(HorizonSourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HorizonSourceStatus) DeepCopyInto(out *HorizonSourceStatus) { + *out = *in + in.SourceStatus.DeepCopyInto(&out.SourceStatus) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HorizonSourceStatus. +func (in *HorizonSourceStatus) DeepCopy() *HorizonSourceStatus { + if in == nil { + return nil + } + out := new(HorizonSourceStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VAuthSpec) DeepCopyInto(out *VAuthSpec) { *out = *in diff --git a/pkg/client/clientset/versioned/typed/sources/v1alpha1/fake/fake_horizonsource.go b/pkg/client/clientset/versioned/typed/sources/v1alpha1/fake/fake_horizonsource.go new file mode 100644 index 000000000..5f89d3942 --- /dev/null +++ b/pkg/client/clientset/versioned/typed/sources/v1alpha1/fake/fake_horizonsource.go @@ -0,0 +1,131 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + + v1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeHorizonSources implements HorizonSourceInterface +type FakeHorizonSources struct { + Fake *FakeSourcesV1alpha1 + ns string +} + +var horizonsourcesResource = schema.GroupVersionResource{Group: "sources.tanzu.vmware.com", Version: "v1alpha1", Resource: "horizonsources"} + +var horizonsourcesKind = schema.GroupVersionKind{Group: "sources.tanzu.vmware.com", Version: "v1alpha1", Kind: "HorizonSource"} + +// Get takes name of the horizonSource, and returns the corresponding horizonSource object, and an error if there is any. +func (c *FakeHorizonSources) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.HorizonSource, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(horizonsourcesResource, c.ns, name), &v1alpha1.HorizonSource{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.HorizonSource), err +} + +// List takes label and field selectors, and returns the list of HorizonSources that match those selectors. +func (c *FakeHorizonSources) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.HorizonSourceList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(horizonsourcesResource, horizonsourcesKind, c.ns, opts), &v1alpha1.HorizonSourceList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.HorizonSourceList{ListMeta: obj.(*v1alpha1.HorizonSourceList).ListMeta} + for _, item := range obj.(*v1alpha1.HorizonSourceList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested horizonSources. +func (c *FakeHorizonSources) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(horizonsourcesResource, c.ns, opts)) + +} + +// Create takes the representation of a horizonSource and creates it. Returns the server's representation of the horizonSource, and an error, if there is any. +func (c *FakeHorizonSources) Create(ctx context.Context, horizonSource *v1alpha1.HorizonSource, opts v1.CreateOptions) (result *v1alpha1.HorizonSource, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(horizonsourcesResource, c.ns, horizonSource), &v1alpha1.HorizonSource{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.HorizonSource), err +} + +// Update takes the representation of a horizonSource and updates it. Returns the server's representation of the horizonSource, and an error, if there is any. +func (c *FakeHorizonSources) Update(ctx context.Context, horizonSource *v1alpha1.HorizonSource, opts v1.UpdateOptions) (result *v1alpha1.HorizonSource, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(horizonsourcesResource, c.ns, horizonSource), &v1alpha1.HorizonSource{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.HorizonSource), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeHorizonSources) UpdateStatus(ctx context.Context, horizonSource *v1alpha1.HorizonSource, opts v1.UpdateOptions) (*v1alpha1.HorizonSource, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(horizonsourcesResource, "status", c.ns, horizonSource), &v1alpha1.HorizonSource{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.HorizonSource), err +} + +// Delete takes name of the horizonSource and deletes it. Returns an error if one occurs. +func (c *FakeHorizonSources) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteActionWithOptions(horizonsourcesResource, c.ns, name, opts), &v1alpha1.HorizonSource{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeHorizonSources) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(horizonsourcesResource, c.ns, listOpts) + + _, err := c.Fake.Invokes(action, &v1alpha1.HorizonSourceList{}) + return err +} + +// Patch applies the patch and returns the patched horizonSource. +func (c *FakeHorizonSources) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.HorizonSource, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(horizonsourcesResource, c.ns, name, pt, data, subresources...), &v1alpha1.HorizonSource{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.HorizonSource), err +} diff --git a/pkg/client/clientset/versioned/typed/sources/v1alpha1/fake/fake_sources_client.go b/pkg/client/clientset/versioned/typed/sources/v1alpha1/fake/fake_sources_client.go index e8762b205..290b05e0a 100644 --- a/pkg/client/clientset/versioned/typed/sources/v1alpha1/fake/fake_sources_client.go +++ b/pkg/client/clientset/versioned/typed/sources/v1alpha1/fake/fake_sources_client.go @@ -17,6 +17,10 @@ type FakeSourcesV1alpha1 struct { *testing.Fake } +func (c *FakeSourcesV1alpha1) HorizonSources(namespace string) v1alpha1.HorizonSourceInterface { + return &FakeHorizonSources{c, namespace} +} + func (c *FakeSourcesV1alpha1) VSphereBindings(namespace string) v1alpha1.VSphereBindingInterface { return &FakeVSphereBindings{c, namespace} } diff --git a/pkg/client/clientset/versioned/typed/sources/v1alpha1/generated_expansion.go b/pkg/client/clientset/versioned/typed/sources/v1alpha1/generated_expansion.go index 67c212ad2..fcf9f8540 100644 --- a/pkg/client/clientset/versioned/typed/sources/v1alpha1/generated_expansion.go +++ b/pkg/client/clientset/versioned/typed/sources/v1alpha1/generated_expansion.go @@ -7,6 +7,8 @@ SPDX-License-Identifier: Apache-2.0 package v1alpha1 +type HorizonSourceExpansion interface{} + type VSphereBindingExpansion interface{} type VSphereSourceExpansion interface{} diff --git a/pkg/client/clientset/versioned/typed/sources/v1alpha1/horizonsource.go b/pkg/client/clientset/versioned/typed/sources/v1alpha1/horizonsource.go new file mode 100644 index 000000000..093aaa0cb --- /dev/null +++ b/pkg/client/clientset/versioned/typed/sources/v1alpha1/horizonsource.go @@ -0,0 +1,184 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + "time" + + v1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + scheme "github.com/vmware-tanzu/sources-for-knative/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// HorizonSourcesGetter has a method to return a HorizonSourceInterface. +// A group's client should implement this interface. +type HorizonSourcesGetter interface { + HorizonSources(namespace string) HorizonSourceInterface +} + +// HorizonSourceInterface has methods to work with HorizonSource resources. +type HorizonSourceInterface interface { + Create(ctx context.Context, horizonSource *v1alpha1.HorizonSource, opts v1.CreateOptions) (*v1alpha1.HorizonSource, error) + Update(ctx context.Context, horizonSource *v1alpha1.HorizonSource, opts v1.UpdateOptions) (*v1alpha1.HorizonSource, error) + UpdateStatus(ctx context.Context, horizonSource *v1alpha1.HorizonSource, opts v1.UpdateOptions) (*v1alpha1.HorizonSource, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.HorizonSource, error) + List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.HorizonSourceList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.HorizonSource, err error) + HorizonSourceExpansion +} + +// horizonSources implements HorizonSourceInterface +type horizonSources struct { + client rest.Interface + ns string +} + +// newHorizonSources returns a HorizonSources +func newHorizonSources(c *SourcesV1alpha1Client, namespace string) *horizonSources { + return &horizonSources{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the horizonSource, and returns the corresponding horizonSource object, and an error if there is any. +func (c *horizonSources) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.HorizonSource, err error) { + result = &v1alpha1.HorizonSource{} + err = c.client.Get(). + Namespace(c.ns). + Resource("horizonsources"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(ctx). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of HorizonSources that match those selectors. +func (c *horizonSources) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.HorizonSourceList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v1alpha1.HorizonSourceList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("horizonsources"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(ctx). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested horizonSources. +func (c *horizonSources) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("horizonsources"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch(ctx) +} + +// Create takes the representation of a horizonSource and creates it. Returns the server's representation of the horizonSource, and an error, if there is any. +func (c *horizonSources) Create(ctx context.Context, horizonSource *v1alpha1.HorizonSource, opts v1.CreateOptions) (result *v1alpha1.HorizonSource, err error) { + result = &v1alpha1.HorizonSource{} + err = c.client.Post(). + Namespace(c.ns). + Resource("horizonsources"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(horizonSource). + Do(ctx). + Into(result) + return +} + +// Update takes the representation of a horizonSource and updates it. Returns the server's representation of the horizonSource, and an error, if there is any. +func (c *horizonSources) Update(ctx context.Context, horizonSource *v1alpha1.HorizonSource, opts v1.UpdateOptions) (result *v1alpha1.HorizonSource, err error) { + result = &v1alpha1.HorizonSource{} + err = c.client.Put(). + Namespace(c.ns). + Resource("horizonsources"). + Name(horizonSource.Name). + VersionedParams(&opts, scheme.ParameterCodec). + Body(horizonSource). + Do(ctx). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *horizonSources) UpdateStatus(ctx context.Context, horizonSource *v1alpha1.HorizonSource, opts v1.UpdateOptions) (result *v1alpha1.HorizonSource, err error) { + result = &v1alpha1.HorizonSource{} + err = c.client.Put(). + Namespace(c.ns). + Resource("horizonsources"). + Name(horizonSource.Name). + SubResource("status"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(horizonSource). + Do(ctx). + Into(result) + return +} + +// Delete takes name of the horizonSource and deletes it. Returns an error if one occurs. +func (c *horizonSources) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("horizonsources"). + Name(name). + Body(&opts). + Do(ctx). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *horizonSources) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + var timeout time.Duration + if listOpts.TimeoutSeconds != nil { + timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Namespace(c.ns). + Resource("horizonsources"). + VersionedParams(&listOpts, scheme.ParameterCodec). + Timeout(timeout). + Body(&opts). + Do(ctx). + Error() +} + +// Patch applies the patch and returns the patched horizonSource. +func (c *horizonSources) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.HorizonSource, err error) { + result = &v1alpha1.HorizonSource{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("horizonsources"). + Name(name). + SubResource(subresources...). + VersionedParams(&opts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} diff --git a/pkg/client/clientset/versioned/typed/sources/v1alpha1/sources_client.go b/pkg/client/clientset/versioned/typed/sources/v1alpha1/sources_client.go index cdac9cc57..1b32a2c76 100644 --- a/pkg/client/clientset/versioned/typed/sources/v1alpha1/sources_client.go +++ b/pkg/client/clientset/versioned/typed/sources/v1alpha1/sources_client.go @@ -17,6 +17,7 @@ import ( type SourcesV1alpha1Interface interface { RESTClient() rest.Interface + HorizonSourcesGetter VSphereBindingsGetter VSphereSourcesGetter } @@ -26,6 +27,10 @@ type SourcesV1alpha1Client struct { restClient rest.Interface } +func (c *SourcesV1alpha1Client) HorizonSources(namespace string) HorizonSourceInterface { + return newHorizonSources(c, namespace) +} + func (c *SourcesV1alpha1Client) VSphereBindings(namespace string) VSphereBindingInterface { return newVSphereBindings(c, namespace) } diff --git a/pkg/client/informers/externalversions/generic.go b/pkg/client/informers/externalversions/generic.go index 6c272ea61..c90a4dbaa 100644 --- a/pkg/client/informers/externalversions/generic.go +++ b/pkg/client/informers/externalversions/generic.go @@ -42,6 +42,8 @@ func (f *genericInformer) Lister() cache.GenericLister { func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { switch resource { // Group=sources.tanzu.vmware.com, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithResource("horizonsources"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Sources().V1alpha1().HorizonSources().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("vspherebindings"): return &genericInformer{resource: resource.GroupResource(), informer: f.Sources().V1alpha1().VSphereBindings().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("vspheresources"): diff --git a/pkg/client/informers/externalversions/sources/v1alpha1/horizonsource.go b/pkg/client/informers/externalversions/sources/v1alpha1/horizonsource.go new file mode 100644 index 000000000..58aa41b03 --- /dev/null +++ b/pkg/client/informers/externalversions/sources/v1alpha1/horizonsource.go @@ -0,0 +1,79 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + time "time" + + sourcesv1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + versioned "github.com/vmware-tanzu/sources-for-knative/pkg/client/clientset/versioned" + internalinterfaces "github.com/vmware-tanzu/sources-for-knative/pkg/client/informers/externalversions/internalinterfaces" + v1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/client/listers/sources/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// HorizonSourceInformer provides access to a shared informer and lister for +// HorizonSources. +type HorizonSourceInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.HorizonSourceLister +} + +type horizonSourceInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewHorizonSourceInformer constructs a new informer for HorizonSource type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewHorizonSourceInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHorizonSourceInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredHorizonSourceInformer constructs a new informer for HorizonSource type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredHorizonSourceInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.SourcesV1alpha1().HorizonSources(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.SourcesV1alpha1().HorizonSources(namespace).Watch(context.TODO(), options) + }, + }, + &sourcesv1alpha1.HorizonSource{}, + resyncPeriod, + indexers, + ) +} + +func (f *horizonSourceInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHorizonSourceInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *horizonSourceInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&sourcesv1alpha1.HorizonSource{}, f.defaultInformer) +} + +func (f *horizonSourceInformer) Lister() v1alpha1.HorizonSourceLister { + return v1alpha1.NewHorizonSourceLister(f.Informer().GetIndexer()) +} diff --git a/pkg/client/informers/externalversions/sources/v1alpha1/interface.go b/pkg/client/informers/externalversions/sources/v1alpha1/interface.go index 42e90ff9a..e8e918854 100644 --- a/pkg/client/informers/externalversions/sources/v1alpha1/interface.go +++ b/pkg/client/informers/externalversions/sources/v1alpha1/interface.go @@ -13,6 +13,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // HorizonSources returns a HorizonSourceInformer. + HorizonSources() HorizonSourceInformer // VSphereBindings returns a VSphereBindingInformer. VSphereBindings() VSphereBindingInformer // VSphereSources returns a VSphereSourceInformer. @@ -30,6 +32,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } +// HorizonSources returns a HorizonSourceInformer. +func (v *version) HorizonSources() HorizonSourceInformer { + return &horizonSourceInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // VSphereBindings returns a VSphereBindingInformer. func (v *version) VSphereBindings() VSphereBindingInformer { return &vSphereBindingInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/pkg/client/injection/client/client.go b/pkg/client/injection/client/client.go index 2a69e2d25..f522b7f24 100644 --- a/pkg/client/injection/client/client.go +++ b/pkg/client/injection/client/client.go @@ -100,6 +100,137 @@ func (w *wrapSourcesV1alpha1) RESTClient() rest.Interface { panic("RESTClient called on dynamic client!") } +func (w *wrapSourcesV1alpha1) HorizonSources(namespace string) typedsourcesv1alpha1.HorizonSourceInterface { + return &wrapSourcesV1alpha1HorizonSourceImpl{ + dyn: w.dyn.Resource(schema.GroupVersionResource{ + Group: "sources.tanzu.vmware.com", + Version: "v1alpha1", + Resource: "horizonsources", + }), + + namespace: namespace, + } +} + +type wrapSourcesV1alpha1HorizonSourceImpl struct { + dyn dynamic.NamespaceableResourceInterface + + namespace string +} + +var _ typedsourcesv1alpha1.HorizonSourceInterface = (*wrapSourcesV1alpha1HorizonSourceImpl)(nil) + +func (w *wrapSourcesV1alpha1HorizonSourceImpl) Create(ctx context.Context, in *v1alpha1.HorizonSource, opts v1.CreateOptions) (*v1alpha1.HorizonSource, error) { + in.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "sources.tanzu.vmware.com", + Version: "v1alpha1", + Kind: "HorizonSource", + }) + uo := &unstructured.Unstructured{} + if err := convert(in, uo); err != nil { + return nil, err + } + uo, err := w.dyn.Namespace(w.namespace).Create(ctx, uo, opts) + if err != nil { + return nil, err + } + out := &v1alpha1.HorizonSource{} + if err := convert(uo, out); err != nil { + return nil, err + } + return out, nil +} + +func (w *wrapSourcesV1alpha1HorizonSourceImpl) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return w.dyn.Namespace(w.namespace).Delete(ctx, name, opts) +} + +func (w *wrapSourcesV1alpha1HorizonSourceImpl) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + return w.dyn.Namespace(w.namespace).DeleteCollection(ctx, opts, listOpts) +} + +func (w *wrapSourcesV1alpha1HorizonSourceImpl) Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.HorizonSource, error) { + uo, err := w.dyn.Namespace(w.namespace).Get(ctx, name, opts) + if err != nil { + return nil, err + } + out := &v1alpha1.HorizonSource{} + if err := convert(uo, out); err != nil { + return nil, err + } + return out, nil +} + +func (w *wrapSourcesV1alpha1HorizonSourceImpl) List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.HorizonSourceList, error) { + uo, err := w.dyn.Namespace(w.namespace).List(ctx, opts) + if err != nil { + return nil, err + } + out := &v1alpha1.HorizonSourceList{} + if err := convert(uo, out); err != nil { + return nil, err + } + return out, nil +} + +func (w *wrapSourcesV1alpha1HorizonSourceImpl) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.HorizonSource, err error) { + uo, err := w.dyn.Namespace(w.namespace).Patch(ctx, name, pt, data, opts) + if err != nil { + return nil, err + } + out := &v1alpha1.HorizonSource{} + if err := convert(uo, out); err != nil { + return nil, err + } + return out, nil +} + +func (w *wrapSourcesV1alpha1HorizonSourceImpl) Update(ctx context.Context, in *v1alpha1.HorizonSource, opts v1.UpdateOptions) (*v1alpha1.HorizonSource, error) { + in.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "sources.tanzu.vmware.com", + Version: "v1alpha1", + Kind: "HorizonSource", + }) + uo := &unstructured.Unstructured{} + if err := convert(in, uo); err != nil { + return nil, err + } + uo, err := w.dyn.Namespace(w.namespace).Update(ctx, uo, opts) + if err != nil { + return nil, err + } + out := &v1alpha1.HorizonSource{} + if err := convert(uo, out); err != nil { + return nil, err + } + return out, nil +} + +func (w *wrapSourcesV1alpha1HorizonSourceImpl) UpdateStatus(ctx context.Context, in *v1alpha1.HorizonSource, opts v1.UpdateOptions) (*v1alpha1.HorizonSource, error) { + in.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "sources.tanzu.vmware.com", + Version: "v1alpha1", + Kind: "HorizonSource", + }) + uo := &unstructured.Unstructured{} + if err := convert(in, uo); err != nil { + return nil, err + } + uo, err := w.dyn.Namespace(w.namespace).UpdateStatus(ctx, uo, opts) + if err != nil { + return nil, err + } + out := &v1alpha1.HorizonSource{} + if err := convert(uo, out); err != nil { + return nil, err + } + return out, nil +} + +func (w *wrapSourcesV1alpha1HorizonSourceImpl) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return nil, errors.New("NYI: Watch") +} + func (w *wrapSourcesV1alpha1) VSphereBindings(namespace string) typedsourcesv1alpha1.VSphereBindingInterface { return &wrapSourcesV1alpha1VSphereBindingImpl{ dyn: w.dyn.Resource(schema.GroupVersionResource{ diff --git a/pkg/client/injection/informers/sources/v1alpha1/horizonsource/fake/fake.go b/pkg/client/injection/informers/sources/v1alpha1/horizonsource/fake/fake.go new file mode 100644 index 000000000..179ef4fa5 --- /dev/null +++ b/pkg/client/injection/informers/sources/v1alpha1/horizonsource/fake/fake.go @@ -0,0 +1,29 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by injection-gen. DO NOT EDIT. + +package fake + +import ( + context "context" + + fake "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/informers/factory/fake" + horizonsource "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/informers/sources/v1alpha1/horizonsource" + controller "knative.dev/pkg/controller" + injection "knative.dev/pkg/injection" +) + +var Get = horizonsource.Get + +func init() { + injection.Fake.RegisterInformer(withInformer) +} + +func withInformer(ctx context.Context) (context.Context, controller.Informer) { + f := fake.Get(ctx) + inf := f.Sources().V1alpha1().HorizonSources() + return context.WithValue(ctx, horizonsource.Key{}, inf), inf.Informer() +} diff --git a/pkg/client/injection/informers/sources/v1alpha1/horizonsource/filtered/fake/fake.go b/pkg/client/injection/informers/sources/v1alpha1/horizonsource/filtered/fake/fake.go new file mode 100644 index 000000000..c7af7b755 --- /dev/null +++ b/pkg/client/injection/informers/sources/v1alpha1/horizonsource/filtered/fake/fake.go @@ -0,0 +1,41 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by injection-gen. DO NOT EDIT. + +package fake + +import ( + context "context" + + factoryfiltered "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/informers/factory/filtered" + filtered "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/informers/sources/v1alpha1/horizonsource/filtered" + controller "knative.dev/pkg/controller" + injection "knative.dev/pkg/injection" + logging "knative.dev/pkg/logging" +) + +var Get = filtered.Get + +func init() { + injection.Fake.RegisterFilteredInformers(withInformer) +} + +func withInformer(ctx context.Context) (context.Context, []controller.Informer) { + untyped := ctx.Value(factoryfiltered.LabelKey{}) + if untyped == nil { + logging.FromContext(ctx).Panic( + "Unable to fetch labelkey from context.") + } + labelSelectors := untyped.([]string) + infs := []controller.Informer{} + for _, selector := range labelSelectors { + f := factoryfiltered.Get(ctx, selector) + inf := f.Sources().V1alpha1().HorizonSources() + ctx = context.WithValue(ctx, filtered.Key{Selector: selector}, inf) + infs = append(infs, inf.Informer()) + } + return ctx, infs +} diff --git a/pkg/client/injection/informers/sources/v1alpha1/horizonsource/filtered/horizonsource.go b/pkg/client/injection/informers/sources/v1alpha1/horizonsource/filtered/horizonsource.go new file mode 100644 index 000000000..8759374dc --- /dev/null +++ b/pkg/client/injection/informers/sources/v1alpha1/horizonsource/filtered/horizonsource.go @@ -0,0 +1,125 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by injection-gen. DO NOT EDIT. + +package filtered + +import ( + context "context" + + apissourcesv1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + versioned "github.com/vmware-tanzu/sources-for-knative/pkg/client/clientset/versioned" + v1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/client/informers/externalversions/sources/v1alpha1" + client "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/client" + filtered "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/informers/factory/filtered" + sourcesv1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/client/listers/sources/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + cache "k8s.io/client-go/tools/cache" + controller "knative.dev/pkg/controller" + injection "knative.dev/pkg/injection" + logging "knative.dev/pkg/logging" +) + +func init() { + injection.Default.RegisterFilteredInformers(withInformer) + injection.Dynamic.RegisterDynamicInformer(withDynamicInformer) +} + +// Key is used for associating the Informer inside the context.Context. +type Key struct { + Selector string +} + +func withInformer(ctx context.Context) (context.Context, []controller.Informer) { + untyped := ctx.Value(filtered.LabelKey{}) + if untyped == nil { + logging.FromContext(ctx).Panic( + "Unable to fetch labelkey from context.") + } + labelSelectors := untyped.([]string) + infs := []controller.Informer{} + for _, selector := range labelSelectors { + f := filtered.Get(ctx, selector) + inf := f.Sources().V1alpha1().HorizonSources() + ctx = context.WithValue(ctx, Key{Selector: selector}, inf) + infs = append(infs, inf.Informer()) + } + return ctx, infs +} + +func withDynamicInformer(ctx context.Context) context.Context { + untyped := ctx.Value(filtered.LabelKey{}) + if untyped == nil { + logging.FromContext(ctx).Panic( + "Unable to fetch labelkey from context.") + } + labelSelectors := untyped.([]string) + for _, selector := range labelSelectors { + inf := &wrapper{client: client.Get(ctx), selector: selector} + ctx = context.WithValue(ctx, Key{Selector: selector}, inf) + } + return ctx +} + +// Get extracts the typed informer from the context. +func Get(ctx context.Context, selector string) v1alpha1.HorizonSourceInformer { + untyped := ctx.Value(Key{Selector: selector}) + if untyped == nil { + logging.FromContext(ctx).Panicf( + "Unable to fetch github.com/vmware-tanzu/sources-for-knative/pkg/client/informers/externalversions/sources/v1alpha1.HorizonSourceInformer with selector %s from context.", selector) + } + return untyped.(v1alpha1.HorizonSourceInformer) +} + +type wrapper struct { + client versioned.Interface + + namespace string + + selector string +} + +var _ v1alpha1.HorizonSourceInformer = (*wrapper)(nil) +var _ sourcesv1alpha1.HorizonSourceLister = (*wrapper)(nil) + +func (w *wrapper) Informer() cache.SharedIndexInformer { + return cache.NewSharedIndexInformer(nil, &apissourcesv1alpha1.HorizonSource{}, 0, nil) +} + +func (w *wrapper) Lister() sourcesv1alpha1.HorizonSourceLister { + return w +} + +func (w *wrapper) HorizonSources(namespace string) sourcesv1alpha1.HorizonSourceNamespaceLister { + return &wrapper{client: w.client, namespace: namespace, selector: w.selector} +} + +func (w *wrapper) List(selector labels.Selector) (ret []*apissourcesv1alpha1.HorizonSource, err error) { + reqs, err := labels.ParseToRequirements(w.selector) + if err != nil { + return nil, err + } + selector = selector.Add(reqs...) + lo, err := w.client.SourcesV1alpha1().HorizonSources(w.namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector.String(), + // TODO(mattmoor): Incorporate resourceVersion bounds based on staleness criteria. + }) + if err != nil { + return nil, err + } + for idx := range lo.Items { + ret = append(ret, &lo.Items[idx]) + } + return ret, nil +} + +func (w *wrapper) Get(name string) (*apissourcesv1alpha1.HorizonSource, error) { + // TODO(mattmoor): Check that the fetched object matches the selector. + return w.client.SourcesV1alpha1().HorizonSources(w.namespace).Get(context.TODO(), name, v1.GetOptions{ + // TODO(mattmoor): Incorporate resourceVersion bounds based on staleness criteria. + }) +} diff --git a/pkg/client/injection/informers/sources/v1alpha1/horizonsource/horizonsource.go b/pkg/client/injection/informers/sources/v1alpha1/horizonsource/horizonsource.go new file mode 100644 index 000000000..ccb753a3e --- /dev/null +++ b/pkg/client/injection/informers/sources/v1alpha1/horizonsource/horizonsource.go @@ -0,0 +1,105 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by injection-gen. DO NOT EDIT. + +package horizonsource + +import ( + context "context" + + apissourcesv1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + versioned "github.com/vmware-tanzu/sources-for-knative/pkg/client/clientset/versioned" + v1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/client/informers/externalversions/sources/v1alpha1" + client "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/client" + factory "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/informers/factory" + sourcesv1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/client/listers/sources/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + cache "k8s.io/client-go/tools/cache" + controller "knative.dev/pkg/controller" + injection "knative.dev/pkg/injection" + logging "knative.dev/pkg/logging" +) + +func init() { + injection.Default.RegisterInformer(withInformer) + injection.Dynamic.RegisterDynamicInformer(withDynamicInformer) +} + +// Key is used for associating the Informer inside the context.Context. +type Key struct{} + +func withInformer(ctx context.Context) (context.Context, controller.Informer) { + f := factory.Get(ctx) + inf := f.Sources().V1alpha1().HorizonSources() + return context.WithValue(ctx, Key{}, inf), inf.Informer() +} + +func withDynamicInformer(ctx context.Context) context.Context { + inf := &wrapper{client: client.Get(ctx), resourceVersion: injection.GetResourceVersion(ctx)} + return context.WithValue(ctx, Key{}, inf) +} + +// Get extracts the typed informer from the context. +func Get(ctx context.Context) v1alpha1.HorizonSourceInformer { + untyped := ctx.Value(Key{}) + if untyped == nil { + logging.FromContext(ctx).Panic( + "Unable to fetch github.com/vmware-tanzu/sources-for-knative/pkg/client/informers/externalversions/sources/v1alpha1.HorizonSourceInformer from context.") + } + return untyped.(v1alpha1.HorizonSourceInformer) +} + +type wrapper struct { + client versioned.Interface + + namespace string + + resourceVersion string +} + +var _ v1alpha1.HorizonSourceInformer = (*wrapper)(nil) +var _ sourcesv1alpha1.HorizonSourceLister = (*wrapper)(nil) + +func (w *wrapper) Informer() cache.SharedIndexInformer { + return cache.NewSharedIndexInformer(nil, &apissourcesv1alpha1.HorizonSource{}, 0, nil) +} + +func (w *wrapper) Lister() sourcesv1alpha1.HorizonSourceLister { + return w +} + +func (w *wrapper) HorizonSources(namespace string) sourcesv1alpha1.HorizonSourceNamespaceLister { + return &wrapper{client: w.client, namespace: namespace, resourceVersion: w.resourceVersion} +} + +// SetResourceVersion allows consumers to adjust the minimum resourceVersion +// used by the underlying client. It is not accessible via the standard +// lister interface, but can be accessed through a user-defined interface and +// an implementation check e.g. rvs, ok := foo.(ResourceVersionSetter) +func (w *wrapper) SetResourceVersion(resourceVersion string) { + w.resourceVersion = resourceVersion +} + +func (w *wrapper) List(selector labels.Selector) (ret []*apissourcesv1alpha1.HorizonSource, err error) { + lo, err := w.client.SourcesV1alpha1().HorizonSources(w.namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector.String(), + ResourceVersion: w.resourceVersion, + }) + if err != nil { + return nil, err + } + for idx := range lo.Items { + ret = append(ret, &lo.Items[idx]) + } + return ret, nil +} + +func (w *wrapper) Get(name string) (*apissourcesv1alpha1.HorizonSource, error) { + return w.client.SourcesV1alpha1().HorizonSources(w.namespace).Get(context.TODO(), name, v1.GetOptions{ + ResourceVersion: w.resourceVersion, + }) +} diff --git a/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource/controller.go b/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource/controller.go new file mode 100644 index 000000000..33590e567 --- /dev/null +++ b/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource/controller.go @@ -0,0 +1,151 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by injection-gen. DO NOT EDIT. + +package horizonsource + +import ( + context "context" + fmt "fmt" + reflect "reflect" + strings "strings" + + versionedscheme "github.com/vmware-tanzu/sources-for-knative/pkg/client/clientset/versioned/scheme" + client "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/client" + horizonsource "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/informers/sources/v1alpha1/horizonsource" + zap "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + labels "k8s.io/apimachinery/pkg/labels" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + scheme "k8s.io/client-go/kubernetes/scheme" + v1 "k8s.io/client-go/kubernetes/typed/core/v1" + record "k8s.io/client-go/tools/record" + kubeclient "knative.dev/pkg/client/injection/kube/client" + controller "knative.dev/pkg/controller" + logging "knative.dev/pkg/logging" + logkey "knative.dev/pkg/logging/logkey" + reconciler "knative.dev/pkg/reconciler" +) + +const ( + defaultControllerAgentName = "horizonsource-controller" + defaultFinalizerName = "horizonsources.sources.tanzu.vmware.com" +) + +// NewImpl returns a controller.Impl that handles queuing and feeding work from +// the queue through an implementation of controller.Reconciler, delegating to +// the provided Interface and optional Finalizer methods. OptionsFn is used to return +// controller.ControllerOptions to be used by the internal reconciler. +func NewImpl(ctx context.Context, r Interface, optionsFns ...controller.OptionsFn) *controller.Impl { + logger := logging.FromContext(ctx) + + // Check the options function input. It should be 0 or 1. + if len(optionsFns) > 1 { + logger.Fatal("Up to one options function is supported, found: ", len(optionsFns)) + } + + horizonsourceInformer := horizonsource.Get(ctx) + + lister := horizonsourceInformer.Lister() + + var promoteFilterFunc func(obj interface{}) bool + + rec := &reconcilerImpl{ + LeaderAwareFuncs: reconciler.LeaderAwareFuncs{ + PromoteFunc: func(bkt reconciler.Bucket, enq func(reconciler.Bucket, types.NamespacedName)) error { + all, err := lister.List(labels.Everything()) + if err != nil { + return err + } + for _, elt := range all { + if promoteFilterFunc != nil { + if ok := promoteFilterFunc(elt); !ok { + continue + } + } + enq(bkt, types.NamespacedName{ + Namespace: elt.GetNamespace(), + Name: elt.GetName(), + }) + } + return nil + }, + }, + Client: client.Get(ctx), + Lister: lister, + reconciler: r, + finalizerName: defaultFinalizerName, + } + + ctrType := reflect.TypeOf(r).Elem() + ctrTypeName := fmt.Sprintf("%s.%s", ctrType.PkgPath(), ctrType.Name()) + ctrTypeName = strings.ReplaceAll(ctrTypeName, "/", ".") + + logger = logger.With( + zap.String(logkey.ControllerType, ctrTypeName), + zap.String(logkey.Kind, "sources.tanzu.vmware.com.HorizonSource"), + ) + + impl := controller.NewContext(ctx, rec, controller.ControllerOptions{WorkQueueName: ctrTypeName, Logger: logger}) + agentName := defaultControllerAgentName + + // Pass impl to the options. Save any optional results. + for _, fn := range optionsFns { + opts := fn(impl) + if opts.ConfigStore != nil { + rec.configStore = opts.ConfigStore + } + if opts.FinalizerName != "" { + rec.finalizerName = opts.FinalizerName + } + if opts.AgentName != "" { + agentName = opts.AgentName + } + if opts.SkipStatusUpdates { + rec.skipStatusUpdates = true + } + if opts.DemoteFunc != nil { + rec.DemoteFunc = opts.DemoteFunc + } + if opts.PromoteFilterFunc != nil { + promoteFilterFunc = opts.PromoteFilterFunc + } + } + + rec.Recorder = createRecorder(ctx, agentName) + + return impl +} + +func createRecorder(ctx context.Context, agentName string) record.EventRecorder { + logger := logging.FromContext(ctx) + + recorder := controller.GetEventRecorder(ctx) + if recorder == nil { + // Create event broadcaster + logger.Debug("Creating event broadcaster") + eventBroadcaster := record.NewBroadcaster() + watches := []watch.Interface{ + eventBroadcaster.StartLogging(logger.Named("event-broadcaster").Infof), + eventBroadcaster.StartRecordingToSink( + &v1.EventSinkImpl{Interface: kubeclient.Get(ctx).CoreV1().Events("")}), + } + recorder = eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: agentName}) + go func() { + <-ctx.Done() + for _, w := range watches { + w.Stop() + } + }() + } + + return recorder +} + +func init() { + versionedscheme.AddToScheme(scheme.Scheme) +} diff --git a/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource/reconciler.go b/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource/reconciler.go new file mode 100644 index 000000000..3182727a6 --- /dev/null +++ b/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource/reconciler.go @@ -0,0 +1,439 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by injection-gen. DO NOT EDIT. + +package horizonsource + +import ( + context "context" + json "encoding/json" + fmt "fmt" + + v1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + versioned "github.com/vmware-tanzu/sources-for-knative/pkg/client/clientset/versioned" + sourcesv1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/client/listers/sources/v1alpha1" + zap "go.uber.org/zap" + v1 "k8s.io/api/core/v1" + equality "k8s.io/apimachinery/pkg/api/equality" + errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + types "k8s.io/apimachinery/pkg/types" + sets "k8s.io/apimachinery/pkg/util/sets" + record "k8s.io/client-go/tools/record" + controller "knative.dev/pkg/controller" + kmp "knative.dev/pkg/kmp" + logging "knative.dev/pkg/logging" + reconciler "knative.dev/pkg/reconciler" +) + +// Interface defines the strongly typed interfaces to be implemented by a +// controller reconciling v1alpha1.HorizonSource. +type Interface interface { + // ReconcileKind implements custom logic to reconcile v1alpha1.HorizonSource. Any changes + // to the objects .Status or .Finalizers will be propagated to the stored + // object. It is recommended that implementors do not call any update calls + // for the Kind inside of ReconcileKind, it is the responsibility of the calling + // controller to propagate those properties. The resource passed to ReconcileKind + // will always have an empty deletion timestamp. + ReconcileKind(ctx context.Context, o *v1alpha1.HorizonSource) reconciler.Event +} + +// Finalizer defines the strongly typed interfaces to be implemented by a +// controller finalizing v1alpha1.HorizonSource. +type Finalizer interface { + // FinalizeKind implements custom logic to finalize v1alpha1.HorizonSource. Any changes + // to the objects .Status or .Finalizers will be ignored. Returning a nil or + // Normal type reconciler.Event will allow the finalizer to be deleted on + // the resource. The resource passed to FinalizeKind will always have a set + // deletion timestamp. + FinalizeKind(ctx context.Context, o *v1alpha1.HorizonSource) reconciler.Event +} + +// ReadOnlyInterface defines the strongly typed interfaces to be implemented by a +// controller reconciling v1alpha1.HorizonSource if they want to process resources for which +// they are not the leader. +type ReadOnlyInterface interface { + // ObserveKind implements logic to observe v1alpha1.HorizonSource. + // This method should not write to the API. + ObserveKind(ctx context.Context, o *v1alpha1.HorizonSource) reconciler.Event +} + +type doReconcile func(ctx context.Context, o *v1alpha1.HorizonSource) reconciler.Event + +// reconcilerImpl implements controller.Reconciler for v1alpha1.HorizonSource resources. +type reconcilerImpl struct { + // LeaderAwareFuncs is inlined to help us implement reconciler.LeaderAware. + reconciler.LeaderAwareFuncs + + // Client is used to write back status updates. + Client versioned.Interface + + // Listers index properties about resources. + Lister sourcesv1alpha1.HorizonSourceLister + + // Recorder is an event recorder for recording Event resources to the + // Kubernetes API. + Recorder record.EventRecorder + + // configStore allows for decorating a context with config maps. + // +optional + configStore reconciler.ConfigStore + + // reconciler is the implementation of the business logic of the resource. + reconciler Interface + + // finalizerName is the name of the finalizer to reconcile. + finalizerName string + + // skipStatusUpdates configures whether or not this reconciler automatically updates + // the status of the reconciled resource. + skipStatusUpdates bool +} + +// Check that our Reconciler implements controller.Reconciler. +var _ controller.Reconciler = (*reconcilerImpl)(nil) + +// Check that our generated Reconciler is always LeaderAware. +var _ reconciler.LeaderAware = (*reconcilerImpl)(nil) + +func NewReconciler(ctx context.Context, logger *zap.SugaredLogger, client versioned.Interface, lister sourcesv1alpha1.HorizonSourceLister, recorder record.EventRecorder, r Interface, options ...controller.Options) controller.Reconciler { + // Check the options function input. It should be 0 or 1. + if len(options) > 1 { + logger.Fatal("Up to one options struct is supported, found: ", len(options)) + } + + // Fail fast when users inadvertently implement the other LeaderAware interface. + // For the typed reconcilers, Promote shouldn't take any arguments. + if _, ok := r.(reconciler.LeaderAware); ok { + logger.Fatalf("%T implements the incorrect LeaderAware interface. Promote() should not take an argument as genreconciler handles the enqueuing automatically.", r) + } + + rec := &reconcilerImpl{ + LeaderAwareFuncs: reconciler.LeaderAwareFuncs{ + PromoteFunc: func(bkt reconciler.Bucket, enq func(reconciler.Bucket, types.NamespacedName)) error { + all, err := lister.List(labels.Everything()) + if err != nil { + return err + } + for _, elt := range all { + // TODO: Consider letting users specify a filter in options. + enq(bkt, types.NamespacedName{ + Namespace: elt.GetNamespace(), + Name: elt.GetName(), + }) + } + return nil + }, + }, + Client: client, + Lister: lister, + Recorder: recorder, + reconciler: r, + finalizerName: defaultFinalizerName, + } + + for _, opts := range options { + if opts.ConfigStore != nil { + rec.configStore = opts.ConfigStore + } + if opts.FinalizerName != "" { + rec.finalizerName = opts.FinalizerName + } + if opts.SkipStatusUpdates { + rec.skipStatusUpdates = true + } + if opts.DemoteFunc != nil { + rec.DemoteFunc = opts.DemoteFunc + } + } + + return rec +} + +// Reconcile implements controller.Reconciler +func (r *reconcilerImpl) Reconcile(ctx context.Context, key string) error { + logger := logging.FromContext(ctx) + + // Initialize the reconciler state. This will convert the namespace/name + // string into a distinct namespace and name, determine if this instance of + // the reconciler is the leader, and any additional interfaces implemented + // by the reconciler. Returns an error is the resource key is invalid. + s, err := newState(key, r) + if err != nil { + logger.Error("Invalid resource key: ", key) + return nil + } + + // If we are not the leader, and we don't implement either ReadOnly + // observer interfaces, then take a fast-path out. + if s.isNotLeaderNorObserver() { + return controller.NewSkipKey(key) + } + + // If configStore is set, attach the frozen configuration to the context. + if r.configStore != nil { + ctx = r.configStore.ToContext(ctx) + } + + // Add the recorder to context. + ctx = controller.WithEventRecorder(ctx, r.Recorder) + + // Get the resource with this namespace/name. + + getter := r.Lister.HorizonSources(s.namespace) + + original, err := getter.Get(s.name) + + if errors.IsNotFound(err) { + // The resource may no longer exist, in which case we stop processing and call + // the ObserveDeletion handler if appropriate. + logger.Debugf("Resource %q no longer exists", key) + if del, ok := r.reconciler.(reconciler.OnDeletionInterface); ok { + return del.ObserveDeletion(ctx, types.NamespacedName{ + Namespace: s.namespace, + Name: s.name, + }) + } + return nil + } else if err != nil { + return err + } + + // Don't modify the informers copy. + resource := original.DeepCopy() + + var reconcileEvent reconciler.Event + + name, do := s.reconcileMethodFor(resource) + // Append the target method to the logger. + logger = logger.With(zap.String("targetMethod", name)) + switch name { + case reconciler.DoReconcileKind: + // Set and update the finalizer on resource if r.reconciler + // implements Finalizer. + if resource, err = r.setFinalizerIfFinalizer(ctx, resource); err != nil { + return fmt.Errorf("failed to set finalizers: %w", err) + } + + if !r.skipStatusUpdates { + reconciler.PreProcessReconcile(ctx, resource) + } + + // Reconcile this copy of the resource and then write back any status + // updates regardless of whether the reconciliation errored out. + reconcileEvent = do(ctx, resource) + + if !r.skipStatusUpdates { + reconciler.PostProcessReconcile(ctx, resource, original) + } + + case reconciler.DoFinalizeKind: + // For finalizing reconcilers, if this resource being marked for deletion + // and reconciled cleanly (nil or normal event), remove the finalizer. + reconcileEvent = do(ctx, resource) + + if resource, err = r.clearFinalizer(ctx, resource, reconcileEvent); err != nil { + return fmt.Errorf("failed to clear finalizers: %w", err) + } + + case reconciler.DoObserveKind: + // Observe any changes to this resource, since we are not the leader. + reconcileEvent = do(ctx, resource) + + } + + // Synchronize the status. + switch { + case r.skipStatusUpdates: + // This reconciler implementation is configured to skip resource updates. + // This may mean this reconciler does not observe spec, but reconciles external changes. + case equality.Semantic.DeepEqual(original.Status, resource.Status): + // If we didn't change anything then don't call updateStatus. + // This is important because the copy we loaded from the injectionInformer's + // cache may be stale and we don't want to overwrite a prior update + // to status with this stale state. + case !s.isLeader: + // High-availability reconcilers may have many replicas watching the resource, but only + // the elected leader is expected to write modifications. + logger.Warn("Saw status changes when we aren't the leader!") + default: + if err = r.updateStatus(ctx, original, resource); err != nil { + logger.Warnw("Failed to update resource status", zap.Error(err)) + r.Recorder.Eventf(resource, v1.EventTypeWarning, "UpdateFailed", + "Failed to update status for %q: %v", resource.Name, err) + return err + } + } + + // Report the reconciler event, if any. + if reconcileEvent != nil { + var event *reconciler.ReconcilerEvent + if reconciler.EventAs(reconcileEvent, &event) { + logger.Infow("Returned an event", zap.Any("event", reconcileEvent)) + r.Recorder.Event(resource, event.EventType, event.Reason, event.Error()) + + // the event was wrapped inside an error, consider the reconciliation as failed + if _, isEvent := reconcileEvent.(*reconciler.ReconcilerEvent); !isEvent { + return reconcileEvent + } + return nil + } + + if controller.IsSkipKey(reconcileEvent) { + // This is a wrapped error, don't emit an event. + } else if ok, _ := controller.IsRequeueKey(reconcileEvent); ok { + // This is a wrapped error, don't emit an event. + } else { + logger.Errorw("Returned an error", zap.Error(reconcileEvent)) + r.Recorder.Event(resource, v1.EventTypeWarning, "InternalError", reconcileEvent.Error()) + } + return reconcileEvent + } + + return nil +} + +func (r *reconcilerImpl) updateStatus(ctx context.Context, existing *v1alpha1.HorizonSource, desired *v1alpha1.HorizonSource) error { + existing = existing.DeepCopy() + return reconciler.RetryUpdateConflicts(func(attempts int) (err error) { + // The first iteration tries to use the injectionInformer's state, subsequent attempts fetch the latest state via API. + if attempts > 0 { + + getter := r.Client.SourcesV1alpha1().HorizonSources(desired.Namespace) + + existing, err = getter.Get(ctx, desired.Name, metav1.GetOptions{}) + if err != nil { + return err + } + } + + // If there's nothing to update, just return. + if equality.Semantic.DeepEqual(existing.Status, desired.Status) { + return nil + } + + if diff, err := kmp.SafeDiff(existing.Status, desired.Status); err == nil && diff != "" { + logging.FromContext(ctx).Debug("Updating status with: ", diff) + } + + existing.Status = desired.Status + + updater := r.Client.SourcesV1alpha1().HorizonSources(existing.Namespace) + + _, err = updater.UpdateStatus(ctx, existing, metav1.UpdateOptions{}) + return err + }) +} + +// updateFinalizersFiltered will update the Finalizers of the resource. +// TODO: this method could be generic and sync all finalizers. For now it only +// updates defaultFinalizerName or its override. +func (r *reconcilerImpl) updateFinalizersFiltered(ctx context.Context, resource *v1alpha1.HorizonSource) (*v1alpha1.HorizonSource, error) { + + getter := r.Lister.HorizonSources(resource.Namespace) + + actual, err := getter.Get(resource.Name) + if err != nil { + return resource, err + } + + // Don't modify the informers copy. + existing := actual.DeepCopy() + + var finalizers []string + + // If there's nothing to update, just return. + existingFinalizers := sets.NewString(existing.Finalizers...) + desiredFinalizers := sets.NewString(resource.Finalizers...) + + if desiredFinalizers.Has(r.finalizerName) { + if existingFinalizers.Has(r.finalizerName) { + // Nothing to do. + return resource, nil + } + // Add the finalizer. + finalizers = append(existing.Finalizers, r.finalizerName) + } else { + if !existingFinalizers.Has(r.finalizerName) { + // Nothing to do. + return resource, nil + } + // Remove the finalizer. + existingFinalizers.Delete(r.finalizerName) + finalizers = existingFinalizers.List() + } + + mergePatch := map[string]interface{}{ + "metadata": map[string]interface{}{ + "finalizers": finalizers, + "resourceVersion": existing.ResourceVersion, + }, + } + + patch, err := json.Marshal(mergePatch) + if err != nil { + return resource, err + } + + patcher := r.Client.SourcesV1alpha1().HorizonSources(resource.Namespace) + + resourceName := resource.Name + updated, err := patcher.Patch(ctx, resourceName, types.MergePatchType, patch, metav1.PatchOptions{}) + if err != nil { + r.Recorder.Eventf(existing, v1.EventTypeWarning, "FinalizerUpdateFailed", + "Failed to update finalizers for %q: %v", resourceName, err) + } else { + r.Recorder.Eventf(updated, v1.EventTypeNormal, "FinalizerUpdate", + "Updated %q finalizers", resource.GetName()) + } + return updated, err +} + +func (r *reconcilerImpl) setFinalizerIfFinalizer(ctx context.Context, resource *v1alpha1.HorizonSource) (*v1alpha1.HorizonSource, error) { + if _, ok := r.reconciler.(Finalizer); !ok { + return resource, nil + } + + finalizers := sets.NewString(resource.Finalizers...) + + // If this resource is not being deleted, mark the finalizer. + if resource.GetDeletionTimestamp().IsZero() { + finalizers.Insert(r.finalizerName) + } + + resource.Finalizers = finalizers.List() + + // Synchronize the finalizers filtered by r.finalizerName. + return r.updateFinalizersFiltered(ctx, resource) +} + +func (r *reconcilerImpl) clearFinalizer(ctx context.Context, resource *v1alpha1.HorizonSource, reconcileEvent reconciler.Event) (*v1alpha1.HorizonSource, error) { + if _, ok := r.reconciler.(Finalizer); !ok { + return resource, nil + } + if resource.GetDeletionTimestamp().IsZero() { + return resource, nil + } + + finalizers := sets.NewString(resource.Finalizers...) + + if reconcileEvent != nil { + var event *reconciler.ReconcilerEvent + if reconciler.EventAs(reconcileEvent, &event) { + if event.EventType == v1.EventTypeNormal { + finalizers.Delete(r.finalizerName) + } + } + } else { + finalizers.Delete(r.finalizerName) + } + + resource.Finalizers = finalizers.List() + + // Synchronize the finalizers filtered by r.finalizerName. + return r.updateFinalizersFiltered(ctx, resource) +} diff --git a/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource/state.go b/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource/state.go new file mode 100644 index 000000000..6baf1438c --- /dev/null +++ b/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource/state.go @@ -0,0 +1,86 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by injection-gen. DO NOT EDIT. + +package horizonsource + +import ( + fmt "fmt" + + v1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + types "k8s.io/apimachinery/pkg/types" + cache "k8s.io/client-go/tools/cache" + reconciler "knative.dev/pkg/reconciler" +) + +// state is used to track the state of a reconciler in a single run. +type state struct { + // key is the original reconciliation key from the queue. + key string + // namespace is the namespace split from the reconciliation key. + namespace string + // name is the name split from the reconciliation key. + name string + // reconciler is the reconciler. + reconciler Interface + // roi is the read only interface cast of the reconciler. + roi ReadOnlyInterface + // isROI (Read Only Interface) the reconciler only observes reconciliation. + isROI bool + // isLeader the instance of the reconciler is the elected leader. + isLeader bool +} + +func newState(key string, r *reconcilerImpl) (*state, error) { + // Convert the namespace/name string into a distinct namespace and name. + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + return nil, fmt.Errorf("invalid resource key: %s", key) + } + + roi, isROI := r.reconciler.(ReadOnlyInterface) + + isLeader := r.IsLeaderFor(types.NamespacedName{ + Namespace: namespace, + Name: name, + }) + + return &state{ + key: key, + namespace: namespace, + name: name, + reconciler: r.reconciler, + roi: roi, + isROI: isROI, + isLeader: isLeader, + }, nil +} + +// isNotLeaderNorObserver checks to see if this reconciler with the current +// state is enabled to do any work or not. +// isNotLeaderNorObserver returns true when there is no work possible for the +// reconciler. +func (s *state) isNotLeaderNorObserver() bool { + if !s.isLeader && !s.isROI { + // If we are not the leader, and we don't implement the ReadOnly + // interface, then take a fast-path out. + return true + } + return false +} + +func (s *state) reconcileMethodFor(o *v1alpha1.HorizonSource) (string, doReconcile) { + if o.GetDeletionTimestamp().IsZero() { + if s.isLeader { + return reconciler.DoReconcileKind, s.reconciler.ReconcileKind + } else if s.isROI { + return reconciler.DoObserveKind, s.roi.ObserveKind + } + } else if fin, ok := s.reconciler.(Finalizer); s.isLeader && ok { + return reconciler.DoFinalizeKind, fin.FinalizeKind + } + return "unknown", nil +} diff --git a/pkg/client/listers/sources/v1alpha1/expansion_generated.go b/pkg/client/listers/sources/v1alpha1/expansion_generated.go index 4a9b37643..587641d53 100644 --- a/pkg/client/listers/sources/v1alpha1/expansion_generated.go +++ b/pkg/client/listers/sources/v1alpha1/expansion_generated.go @@ -7,6 +7,14 @@ SPDX-License-Identifier: Apache-2.0 package v1alpha1 +// HorizonSourceListerExpansion allows custom methods to be added to +// HorizonSourceLister. +type HorizonSourceListerExpansion interface{} + +// HorizonSourceNamespaceListerExpansion allows custom methods to be added to +// HorizonSourceNamespaceLister. +type HorizonSourceNamespaceListerExpansion interface{} + // VSphereBindingListerExpansion allows custom methods to be added to // VSphereBindingLister. type VSphereBindingListerExpansion interface{} diff --git a/pkg/client/listers/sources/v1alpha1/horizonsource.go b/pkg/client/listers/sources/v1alpha1/horizonsource.go new file mode 100644 index 000000000..0ebec6a39 --- /dev/null +++ b/pkg/client/listers/sources/v1alpha1/horizonsource.go @@ -0,0 +1,88 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// HorizonSourceLister helps list HorizonSources. +// All objects returned here must be treated as read-only. +type HorizonSourceLister interface { + // List lists all HorizonSources in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1alpha1.HorizonSource, err error) + // HorizonSources returns an object that can list and get HorizonSources. + HorizonSources(namespace string) HorizonSourceNamespaceLister + HorizonSourceListerExpansion +} + +// horizonSourceLister implements the HorizonSourceLister interface. +type horizonSourceLister struct { + indexer cache.Indexer +} + +// NewHorizonSourceLister returns a new HorizonSourceLister. +func NewHorizonSourceLister(indexer cache.Indexer) HorizonSourceLister { + return &horizonSourceLister{indexer: indexer} +} + +// List lists all HorizonSources in the indexer. +func (s *horizonSourceLister) List(selector labels.Selector) (ret []*v1alpha1.HorizonSource, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.HorizonSource)) + }) + return ret, err +} + +// HorizonSources returns an object that can list and get HorizonSources. +func (s *horizonSourceLister) HorizonSources(namespace string) HorizonSourceNamespaceLister { + return horizonSourceNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// HorizonSourceNamespaceLister helps list and get HorizonSources. +// All objects returned here must be treated as read-only. +type HorizonSourceNamespaceLister interface { + // List lists all HorizonSources in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1alpha1.HorizonSource, err error) + // Get retrieves the HorizonSource from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*v1alpha1.HorizonSource, error) + HorizonSourceNamespaceListerExpansion +} + +// horizonSourceNamespaceLister implements the HorizonSourceNamespaceLister +// interface. +type horizonSourceNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all HorizonSources in the indexer for a given namespace. +func (s horizonSourceNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.HorizonSource, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.HorizonSource)) + }) + return ret, err +} + +// Get retrieves the HorizonSource from the indexer for a given namespace and name. +func (s horizonSourceNamespaceLister) Get(name string) (*v1alpha1.HorizonSource, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("horizonsource"), name) + } + return obj.(*v1alpha1.HorizonSource), nil +} diff --git a/pkg/horizon/adapter.go b/pkg/horizon/adapter.go new file mode 100644 index 000000000..1146c3f00 --- /dev/null +++ b/pkg/horizon/adapter.go @@ -0,0 +1,269 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizon + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/benbjohnson/clock" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/jpillora/backoff" + "github.com/kelseyhightower/envconfig" + "go.uber.org/zap" + "knative.dev/eventing/pkg/adapter/v2" + "knative.dev/pkg/logging" +) + +const ( + defaultPollInterval = time.Second + eventTypeFormat = "com.vmware.horizon.%s.v0" + + retryBackoff = time.Second + retryMaxTries = 5 +) + +type envConfig struct { + // Include the standard adapter.EnvConfig used by all adapters. + adapter.EnvConfig + + // Horizon settings + Address string `envconfig:"HORIZON_URL" required:"true"` + Insecure bool `envconfig:"HORIZON_INSECURE" default:"false"` + // overwrite useful for local development + SecretPath string `envconfig:"HORIZON_SECRET_PATH" default:""` +} + +func NewEnv() adapter.EnvConfigAccessor { return &envConfig{} } + +// Adapter reads events from the VMware Horizon API +type Adapter struct { + client cloudevents.Client + + source string + sink string + hclient Client + clock clock.Clock + pollInterval time.Duration +} + +func NewAdapter(ctx context.Context, _ adapter.EnvConfigAccessor, ceClient cloudevents.Client) adapter.Adapter { + logger := logging.FromContext(ctx) + + var env envConfig + if err := envconfig.Process("", &env); err != nil { + logger.Fatalw("process environment variables", zap.Error(err)) + } + + hc, err := newHorizonClient(ctx) + if err != nil { + logger.Fatalw("create horizon client", zap.Error(err)) + } + + return &Adapter{ + client: ceClient, + source: env.Address, + sink: env.GetSink(), + hclient: hc, + clock: clock.New(), + pollInterval: defaultPollInterval, + } +} + +// Start runs the adapter. Returns if ctx is cancelled or on unrecoverable +// error, e.g. reading or sending events. +func (a *Adapter) Start(ctx context.Context) error { + return a.run(ctx) +} + +// run starts polling the Horizon event API until the specified context is +// cancelled or when an error is returned while retrieving Horizon events +func (a *Adapter) run(ctx context.Context) error { + var ( + lastEvent *AuditEventSummary + since Timestamp + ) + + logger := logging.FromContext(ctx).With( + zap.String("source", a.source), + zap.Duration("pollIntervalSeconds", a.pollInterval), + ) + logger.Infow("starting horizon source adapter") + + ticker := a.clock.Ticker(a.pollInterval) + defer func() { + ticker.Stop() + + if err := a.hclient.Logout(context.Background()); err != nil { + logger.Warn("could not logout from Horizon API", zap.Error(err)) + } + }() + + backoffCfg := backoff.Backoff{ + Factor: 2, + Jitter: false, + Min: retryBackoff, + Max: retryMaxTries * time.Second, + } + + for { + select { + case <-ctx.Done(): + logger.Infof("stopping event stream") + return ctx.Err() + + case <-ticker.C: + if lastEvent != nil { + since = Timestamp(lastEvent.Time) + } + + if since == 0 { + logger.Debug("retrieving initial set of events") + } else { + logger.Debugw("retrieving events with time range filter", + zap.Any("sinceUnixMilli", since), + zap.String("sinceConverted", time.Unix(int64(since/1000), 0).String()), + ) + } + + events, err := a.hclient.GetEvents(ctx, since) + if err != nil { + return fmt.Errorf("get events: %w", err) + } + + skip := false + switch len(events) { + case 0: + skip = true + case 1: // check if this is lastEvent we have already seen + if lastEvent != nil && events[0].ID == lastEvent.ID { + skip = true + } + } + + if skip { + sleep := backoffCfg.Duration() + logger.Debugw("backing off retrieving events: no new events received", zap.Duration("backoffSeconds", sleep)) + time.Sleep(sleep) + continue + } + + logger.Debugw("retrieved new events", zap.Int("count", len(events))) + events = removeEvent(events, lastEvent) + logger.Debugw("remaining new events after filtering out duplicate events", zap.Int("count", len(events))) + lastEvent = a.sendEvents(ctx, events) + backoffCfg.Reset() + } + } +} + +// sendEvents sends the given events to the configured SINK returning the last +// successfully sent event +// +// TODO (@mgasch): There is a risk of poison pill issue here, leading to a constant loop +// in the invoking function. +func (a *Adapter) sendEvents(ctx context.Context, events []AuditEventSummary) *AuditEventSummary { + logger := logging.FromContext(ctx).With( + zap.String("source", a.source), + zap.String("sink", a.sink), + ) + + // Horizon events are returned in descending time order thus the "id" in a + // Horizon event can not be used for ordering (see testdata for example with + // concurrent timestamps) + reverse(events) + + // last successful processed event to track time offset in stream + var lastEvent *AuditEventSummary + + ctx = cloudevents.ContextWithRetriesExponentialBackoff(ctx, retryBackoff, retryMaxTries) + for i := range events { + event := events[i] + // don't waste cycles when ctx canceled + if ctx.Err() != nil { + return lastEvent + } + + log := logger.With(zap.Any("event", event)) + ce, err := toCloudEvent(event, a.source) + if err != nil { + log.Errorw("skipping event because it could not be converted to cloudevent", zap.Error(err)) + continue + } + + // TODO: better partial batch failure handling here? + result := a.client.Send(ctx, ce) + if !cloudevents.IsACK(result) { + log.Errorw("could not send cloudevent", zap.Error(result)) + continue + } + log.Debugw("successfully sent event") + lastEvent = &event + } + + return lastEvent +} + +// reverse mutates the given slice and reverses its order +func reverse(ev []AuditEventSummary) { + for i := len(ev)/2 - 1; i >= 0; i-- { + opp := len(ev) - 1 - i + ev[i], ev[opp] = ev[opp], ev[i] + } +} + +// removeEvent returns a copy of list with the given event removed +func removeEvent(list []AuditEventSummary, event *AuditEventSummary) []AuditEventSummary { + deduped := make([]AuditEventSummary, len(list)) + copy(deduped, list) + + if event == nil { + return deduped + } + + for i := range list { + if list[i].ID == event.ID { + // Remove the element at index i from a. + copy(deduped[i:], deduped[i+1:]) // shift deduped[i+1:] left one index. + deduped[len(deduped)-1] = AuditEventSummary{} // erase last element (write zero value). + deduped = deduped[:len(deduped)-1] // truncate slice. + } + } + return deduped +} + +func toCloudEvent(horizonEvent AuditEventSummary, source string) (cloudevents.Event, error) { + ce := cloudevents.NewEvent() + + // TODO: revisit CE properties used here + id := strconv.Itoa(int(horizonEvent.ID)) + ce.SetID(id) + ce.SetSource(source) + ce.SetType(convertEventType(horizonEvent.Type)) + t := time.Unix(horizonEvent.Time/1000, 0) // time is converted from ms + ce.SetTime(t) + + if err := ce.SetData(cloudevents.ApplicationJSON, horizonEvent); err != nil { + return cloudevents.Event{}, fmt.Errorf("set cloudevent data: %w", err) + } + + if err := ce.Validate(); err != nil { + return cloudevents.Event{}, fmt.Errorf("validation for cloudevent failed: %w", err) + } + + return ce, nil +} + +// convertEventType converts a Horizon event type to a normalized cloud event +// type. For example, VLSI_USERLOGGEDIN is converted to +// com.vmware.horizon.vlsi_userloggedin.v0 +func convertEventType(t string) string { + t = strings.ToLower(t) + return fmt.Sprintf(eventTypeFormat, t) +} diff --git a/pkg/horizon/adapter_test.go b/pkg/horizon/adapter_test.go new file mode 100644 index 000000000..fce1d8525 --- /dev/null +++ b/pkg/horizon/adapter_test.go @@ -0,0 +1,225 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizon + +import ( + "context" + "encoding/json" + "net" + "os" + "os/exec" + "sync" + "testing" + "time" + + "github.com/benbjohnson/clock" + ce "github.com/cloudevents/sdk-go/v2" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + "knative.dev/eventing/pkg/adapter/v2" + "knative.dev/pkg/logging" +) + +const ( + testEvents = "./testdata/audit_events.golden" +) + +func TestAdapter(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + + ctx = logging.WithLogger(ctx, zaptest.NewLogger(t).Sugar()) + + receiver := newSink(t, ctx) + tr, err := ce.NewHTTP(ce.WithTarget(receiver.URL())) + require.NoError(t, err) + + ceClient, err := ce.NewClient(tr) + require.NoError(t, err) + + f, err := os.Open(testEvents) + require.NoErrorf(t, err, "open golden file: %s", testEvents) + + var events []AuditEventSummary + dec := json.NewDecoder(f) + err = dec.Decode(&events) + require.NoError(t, err, "JSON decode test events") + + a := &Adapter{ + client: ceClient, + source: "http://api.horizon.corp.local", + sink: receiver.URL(), + hclient: &horizonMockClient{events: events}, + clock: clock.New(), + pollInterval: time.Millisecond * 100, + } + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + err = a.Start(ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + }() + + wg.Add(1) + go func() { + defer wg.Done() + + var ( + expect = len(events) + + counter int + lastTimestamp time.Time + ) + + for counter != expect { + e := <-receiver.receiveChan + // verify timestamps are ascending + if lastTimestamp.IsZero() { + lastTimestamp = e.Time() + } + + require.GreaterOrEqual(t, e.Time().UnixMilli(), lastTimestamp.UnixMilli()) + counter++ + } + }() + + wg.Wait() +} + +func TestAdapterMain(t *testing.T) { + // Use the test executable to simulate the cmd/adapter process if + // environment var t.Name() is set to "main" + // (see https://talks.golang.org/2014/testing.slide#23) + if os.Getenv(t.Name()) == "main" { + adapter.Main("horizon-source", NewEnv, NewAdapter) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + receiver := newSink(t, ctx) + + // Run a simulated adapter main using the test executable. + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run="+t.Name()) + cmd.Env = append(os.Environ(), + t.Name()+"=main", + "K_SINK="+receiver.URL(), + "INTERVAL="+"1ms", + "NAMESPACE=namespace", + "NAME=name", + `K_METRICS_CONFIG={"domain":"x", "component":"x", "prometheusport":0, "configmap":{}}`, + `K_LOGGING_CONFIG={}`, + ) + err := cmd.Start() + if err != nil { + t.Error(err) + } + defer func() { + if err = cmd.Wait(); err != nil { + t.Logf("wait returned with error: %v", err) + } + }() +} + +func Test_removeItem(t *testing.T) { + t.Run("event is nil", func(t *testing.T) { + ev := createFakeEvents(10) + got := removeEvent(ev, nil) + require.Equal(t, ev, got) + }) + + t.Run("empty events", func(t *testing.T) { + got := removeEvent([]AuditEventSummary{}, &AuditEventSummary{}) + require.Equal(t, []AuditEventSummary{}, got) + }) + + t.Run("one duplicate event", func(t *testing.T) { + ev := createFakeEvents(3) + got := removeEvent(ev, &AuditEventSummary{ID: 10}) + require.Equal(t, ev[1:], got) + }) +} + +// createFakeEvents creates returns a []AuditEventSummary where the ID of each +// element is set to 10 + current counter +func createFakeEvents(count int) []AuditEventSummary { + events := make([]AuditEventSummary, count) + for i := 0; i < count; i++ { + events[i] = AuditEventSummary{ID: int64(10 + i)} + } + + return events +} + +type sink struct { + listener net.Listener + client ce.Client + proto *ce.HTTPProtocol + receiveChan chan ce.Event +} + +func newSink(t *testing.T, ctx context.Context) *sink { + s := &sink{receiveChan: make(chan ce.Event)} + + var err error + s.listener, err = net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + s.proto, err = ce.NewHTTP(ce.WithListener(s.listener)) + require.NoError(t, err) + + s.client, err = ce.NewClient(s.proto) + require.NoError(t, err) + + go func() { + err = s.client.StartReceiver(ctx, func(ctx context.Context, e ce.Event) ce.Result { + select { + case s.receiveChan <- e: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + require.NoError(t, err) + close(s.receiveChan) + }() + + return s +} + +func (s *sink) URL() string { return "http://" + s.listener.Addr().String() } + +type horizonMockClient struct { + invocations int + events []AuditEventSummary // 10 items in golden file +} + +func (h *horizonMockClient) GetEvents(ctx context.Context, since Timestamp) ([]AuditEventSummary, error) { + h.invocations++ + + // Horizon API returns events ordered from newest to oldest + // note: concurrent events (by time) are not ordered by id (see golden file for example) + switch h.invocations { + case 1: + // first half + return h.events[5:], nil + case 2: + return h.events[2:5], nil + case 3: + // two most recent events + return h.events[0:2], nil + default: + // always return newest event, triggers backoff + return h.events[0:1], nil + } +} + +func (h *horizonMockClient) Logout(ctx context.Context) error { + return nil +} diff --git a/pkg/horizon/horizon.go b/pkg/horizon/horizon.go new file mode 100644 index 000000000..f76f88532 --- /dev/null +++ b/pkg/horizon/horizon.go @@ -0,0 +1,355 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizon + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "path/filepath" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/go-resty/resty/v2" + "github.com/kelseyhightower/envconfig" + "github.com/pkg/errors" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "knative.dev/pkg/logging" +) + +const ( + // domainSecretKey is the key (filename) of the projected secret containing the + // Horizon Active Directory Domain to use + domainSecretKey = "domain" + + // DefaultSecretMountPath is the default mount path of the Kubernetes Secret + // containing Horizon credentials + //nolint:gosec + DefaultSecretMountPath = "/var/bindings/horizon" // filepath.Join isn't const. + + // HTTP client + defaultTimeout = time.Second * 5 + defaultRetries = 3 + + // Horizon API + loginPath = "/rest/login" + logoutPath = "/rest/logout" + refreshPath = "/rest/refresh" + eventsPath = "/rest/external/v1/audit-events" +) + +var errTokenExpired = errors.New("refresh token expired") + +// Client gets events from the configured Horizon API REST server +type Client interface { + GetEvents(ctx context.Context, since Timestamp) ([]AuditEventSummary, error) + Logout(ctx context.Context) error +} + +type horizonClient struct { + client *resty.Client + credentials AuthLoginRequest + tokens AuthTokens + logger *zap.SugaredLogger +} + +var _ Client = (*horizonClient)(nil) + +// readSecretKey reads the key from a Kubernetes secret +func readSecretKey(key string) (string, error) { + var env envConfig + if err := envconfig.Process("", &env); err != nil { + return "", fmt.Errorf("process environment variables: %w", err) + } + + mountPath := DefaultSecretMountPath + if env.SecretPath != "" { + mountPath = env.SecretPath + } + + data, err := ioutil.ReadFile(filepath.Join(mountPath, key)) + if err != nil { + return "", err + } + return string(data), nil +} + +func newHorizonClient(ctx context.Context) (*horizonClient, error) { + user, err := readSecretKey(corev1.BasicAuthUsernameKey) + if err != nil { + return nil, fmt.Errorf("read secret key %q: %w", corev1.BasicAuthUsernameKey, err) + } + + pass, err := readSecretKey(corev1.BasicAuthPasswordKey) + if err != nil { + return nil, fmt.Errorf("read secret key %q: %w", corev1.BasicAuthPasswordKey, err) + } + + domain, err := readSecretKey(domainSecretKey) + if err != nil { + return nil, fmt.Errorf("read secret key %q: %w", domainSecretKey, err) + } + + creds := AuthLoginRequest{ + Domain: domain, + Username: user, + Password: pass, + } + + emptyCredentials := func() bool { + if creds.Domain == "" || creds.Username == "" || creds.Password == "" { + return true + } + return false + } + + if emptyCredentials() { + return nil, fmt.Errorf("invalid credentials: domain, username and password must be set") + } + + var env envConfig + if err = envconfig.Process("", &env); err != nil { + return nil, fmt.Errorf("process environment variables: %w", err) + } + + rc := newRESTClient(ctx, env.Address, env.Insecure) + c := horizonClient{ + client: rc, + logger: logging.FromContext(ctx), + credentials: creds, + } + + if env.Insecure { + c.logger.Warnw("using potentially insecure connection to Horizon API server", "address", env.Address, "insecure", env.Insecure) + } + + c.logger.Debug("authenticating against Horizon API") + if err = c.login(ctx); err != nil { + return nil, fmt.Errorf("horizon API login failure: %w", err) + } + + return &c, nil +} + +func newRESTClient(ctx context.Context, server string, insecure bool) *resty.Client { + // REST global client defaults + r := resty.New().SetLogger(logging.FromContext(ctx)) + r.SetBaseURL(server) + r.SetHeader("content-type", cloudevents.ApplicationJSON) + r.SetAuthScheme("Bearer") + r.SetRetryCount(defaultRetries).SetRetryMaxWaitTime(defaultTimeout) + //nolint:gosec + r.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecure}) + + return r +} + +// login performs an authentication request to the Horizon API server, sets and +// stores the returned auth and refresh tokens +func (h *horizonClient) login(ctx context.Context) error { + /* Access tokens would be valid for 30 minutes while the refresh token would be + valid for 8 hours. Once the access token has expired, the user will get a 401 + response from the APIs and would need to get a new access token from the refresh + endpoint using the refresh token. If the Refresh token is also expired (after 8 + hours and when user gets a 400), it indicates that user needs to fully + re-authenticate using login endpoint due to invalid refresh token. + */ + + // check if we can use an existing refresh token + if h.tokens.RefreshToken != "" { + err := h.refresh(ctx) + + // success + if err == nil { + return nil + } + + if !errors.Is(err, errTokenExpired) { + return fmt.Errorf("refresh token: %w", err) + } + } + + // perform full login + res, err := h.client.R().SetContext(ctx).SetBody(h.credentials).Post(loginPath) + if err != nil { + return err + } + + if !res.IsSuccess() { + return fmt.Errorf("horizon API login returned non-success status code: %d", res.StatusCode()) + } + + var tokens AuthTokens + err = json.Unmarshal(res.Body(), &tokens) + if err != nil { + return fmt.Errorf("unmarshal JSON authentication token response: %w", err) + } + + h.tokens = tokens + h.client.SetAuthToken(h.tokens.AccessToken) + h.logger.Debug("Horizon API login successful") + + return nil +} + +// refresh attempts to refresh an expired auth token. If the refresh token has +// expired, errTokenExpired will be returned. +func (h *horizonClient) refresh(ctx context.Context) error { + request := RefreshTokenRequest{h.tokens.RefreshToken} + res, err := h.client.R().SetContext(ctx).SetBody(request).Post(refreshPath) + if err != nil { + return err + } + + if !res.IsSuccess() { + switch res.StatusCode() { + case http.StatusBadRequest: + return errTokenExpired + + default: + return fmt.Errorf("unexpected HTTP response: %d %s", res.StatusCode(), string(res.Body())) + } + } + + var accessToken AccessToken + err = json.Unmarshal(res.Body(), &accessToken) + if err != nil { + return fmt.Errorf("unmarshal JSON access token response: %w", err) + } + + token := accessToken.AccessToken + h.tokens.AccessToken = token + h.client.SetAuthToken(token) + h.logger.Debug("auth token refresh successful") + return nil +} + +// GetEvents returns a list of AuditEventSummary from the Horizon API +func (h *horizonClient) GetEvents(ctx context.Context, since Timestamp) ([]AuditEventSummary, error) { + var ( + res *resty.Response + retries int + err error + + timeRange string + params map[string]string + ) + + // handle auth expired cases + for retries < 2 { + if since == 0 { + // return last (up to) 10 initial events if no timestamp is specified + params = map[string]string{ + "size": "10", + "page": "1", + } + } else { + timeRange, err = timeRangeFilter(since, 0) + h.logger.Debugw("using time range filter", "filter", timeRange) + if err != nil { + return nil, fmt.Errorf("create time range query filter: %w", err) + } + + params = map[string]string{ + "filter": timeRange, + } + } + + req := h.client.R().SetContext(ctx).SetQueryParams(params) + res, err = req.Get(eventsPath) + if err != nil { + return nil, err + } + + h.logger.Debugw("Horizon GetEvents response headers", zap.Any("headers", res.Header())) + h.logger.Debugw("Horizon GetEvents response body", zap.String("body", string(res.Body()))) + + if !res.IsSuccess() { + switch res.StatusCode() { + // perform re-auth + case http.StatusUnauthorized: + if err = h.login(ctx); err != nil { + h.logger.Error(string(res.Body())) + return nil, fmt.Errorf("not authenticated: %w: %s", err, string(res.Body())) + } + h.logger.Debugw("retrying get events after re-authentication", zap.Int("retried", retries)) + retries++ + continue + + // conflict (note: should never happen on GET and incorrectly used in spec for + // DB missing error) + case http.StatusConflict: + return nil, errors.New("HTTP conflict error: 401 (DB not initialized?)") + + // not defined in spec + default: + return nil, fmt.Errorf("unexpected status code: %d %s", res.StatusCode(), string(res.Body())) + } + } + + var events []AuditEventSummary + err = json.Unmarshal(res.Body(), &events) + if err != nil { + return nil, fmt.Errorf("unmarshal JSON audit events response: %w", err) + } + + return events, nil + } + + return nil, fmt.Errorf("get events status code: %d %s", res.StatusCode(), string(res.Body())) +} + +// timeRangeFilter returns the JSON-encoded query string for the given timestamp +// range. Both values are interpreted as inclusive range values. If to is 0 an +// arbitrary time (UTC) in the future is used as the upper range bound. +func timeRangeFilter(from, to Timestamp) (string, error) { + // avoid small clock sync issues between client and server and use 1d as future + // timestamp buffer + timeBuffer := time.Hour * 24 + + if to == 0 { + to = Timestamp(time.Now().Add(timeBuffer).Unix() * 1000) // milliseconds + } + + f := BetweenFilter{ + Type: "Between", + Name: "time", + FromValue: from, + ToValue: to, + } + + filter, err := json.Marshal(f) + if err != nil { + return "", fmt.Errorf("JSON marshal filter: %w", err) + } + + return string(filter), nil +} + +// Logout performs a logout against the Horizon API +func (h *horizonClient) Logout(ctx context.Context) error { + request := RefreshTokenRequest{h.tokens.RefreshToken} + res, err := h.client.R().SetContext(ctx).SetBody(request).Post(logoutPath) + if err != nil { + return err + } + + if !res.IsSuccess() { + switch res.StatusCode() { + case http.StatusBadRequest: + return errors.New("auth token already expired") + + default: + return fmt.Errorf("unexpected status code: code: %d error: %s", res.StatusCode(), string(res.Body())) + } + } + + return nil +} diff --git a/pkg/horizon/horizon_test.go b/pkg/horizon/horizon_test.go new file mode 100644 index 000000000..bc94c19d0 --- /dev/null +++ b/pkg/horizon/horizon_test.go @@ -0,0 +1,318 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizon + +import ( + "context" + "encoding/json" + "math/rand" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +const ( + testDomain = "corp" + testUsername = "user" + testPassword = "password" +) + +func Test_horizonClient_login(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := zaptest.NewLogger(t).Sugar() + + t.Run("successful login", func(t *testing.T) { + ts := newTestServer(ctx) + defer ts.httpSrv.Close() + + h := &horizonClient{ + client: newRESTClient(ctx, ts.httpSrv.URL, false), + credentials: AuthLoginRequest{ + Domain: testDomain, + Username: testUsername, + Password: testPassword, + }, + logger: logger, + } + + err := h.login(ctx) + require.NoError(t, err) + }) + + t.Run("invalid credentials", func(t *testing.T) { + ts := newTestServer(ctx) + defer ts.httpSrv.Close() + + h := &horizonClient{ + client: newRESTClient(ctx, ts.httpSrv.URL, false), + credentials: AuthLoginRequest{ + Domain: testDomain, + Username: "unknown", + Password: "wrong", + }, + logger: logger, + } + + err := h.login(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "401") + }) + + t.Run("client with valid refresh token", func(t *testing.T) { + ts := newTestServer(ctx) + defer ts.httpSrv.Close() + + tsRefreshToken := ts.getTokens().RefreshToken + + h := &horizonClient{ + client: newRESTClient(ctx, ts.httpSrv.URL, false), + tokens: AuthTokens{ + RefreshToken: tsRefreshToken, + }, + logger: logger, + } + + err := h.login(ctx) + require.NoError(t, err) + require.Equal(t, tsRefreshToken, h.tokens.RefreshToken) + }) + + t.Run("client with invalid refresh token triggers re-auth", func(t *testing.T) { + ts := newTestServer(ctx) + defer ts.httpSrv.Close() + + tsRefreshToken := ts.getTokens().RefreshToken + + h := &horizonClient{ + client: newRESTClient(ctx, ts.httpSrv.URL, false), + credentials: AuthLoginRequest{ + Domain: testDomain, + Username: testUsername, + Password: testPassword, + }, + tokens: AuthTokens{ + RefreshToken: "invalid", + }, + logger: logger, + } + + err := h.login(ctx) + require.NoError(t, err) + require.Equal(t, tsRefreshToken, h.tokens.RefreshToken) + }) + + t.Run("client with expired refresh token triggers re-auth", func(t *testing.T) { + ts := newTestServer(ctx) + defer ts.httpSrv.Close() + + currentTokens := ts.getTokens() + ts.rotateTokens() + newTokens := ts.getTokens() + + h := &horizonClient{ + client: newRESTClient(ctx, ts.httpSrv.URL, false), + credentials: AuthLoginRequest{ + Domain: testDomain, + Username: testUsername, + Password: testPassword, + }, + tokens: AuthTokens{ + RefreshToken: currentTokens.RefreshToken, + }, + logger: logger, + } + + err := h.login(ctx) + require.NoError(t, err) + require.Equal(t, newTokens.RefreshToken, h.tokens.RefreshToken) + }) +} + +func Test_horizonClient_Logout(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := zaptest.NewLogger(t).Sugar() + + t.Run("successful logout", func(t *testing.T) { + ts := newTestServer(ctx) + defer ts.httpSrv.Close() + + tsRefreshToken := ts.getTokens().RefreshToken + + h := &horizonClient{ + client: newRESTClient(ctx, ts.httpSrv.URL, false), + tokens: AuthTokens{ + RefreshToken: tsRefreshToken, + }, + logger: logger, + } + + err := h.Logout(ctx) + require.NoError(t, err) + }) + + t.Run("logout throws error with invalid refresh token", func(t *testing.T) { + ts := newTestServer(ctx) + defer ts.httpSrv.Close() + + h := &horizonClient{ + client: newRESTClient(ctx, ts.httpSrv.URL, false), + tokens: AuthTokens{ + RefreshToken: "invalid", + }, + logger: logger, + } + + err := h.Logout(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "expired") + }) + + t.Run("logout with expired context", func(t *testing.T) { + ts := newTestServer(ctx) + defer ts.httpSrv.Close() + + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + + h := &horizonClient{ + client: newRESTClient(ctx, ts.httpSrv.URL, false), + logger: logger, + } + + err := h.Logout(canceledCtx) + assert.ErrorIs(t, err, context.Canceled) + }) +} + +type horizonAPIMock struct { + httpSrv *httptest.Server + + sync.RWMutex + tokens AuthTokens +} + +func newTestServer(_ context.Context) *horizonAPIMock { + mux := http.NewServeMux() + ts := horizonAPIMock{ + httpSrv: httptest.NewServer(mux), + tokens: AuthTokens{ + AccessToken: randomToken(10), + RefreshToken: randomToken(20), + }, + } + + mux.HandleFunc(loginPath, ts.loginHandler) + mux.HandleFunc(logoutPath, ts.logoutHandler) + mux.HandleFunc(refreshPath, ts.refreshHandler) + + return &ts +} + +func (h *horizonAPIMock) rotateTokens() { + h.Lock() + h.tokens.AccessToken = randomToken(10) + h.tokens.RefreshToken = randomToken(20) + h.Unlock() +} + +func (h *horizonAPIMock) getTokens() AuthTokens { + h.RLock() + defer h.RUnlock() + return h.tokens +} + +func (h *horizonAPIMock) loginHandler(w http.ResponseWriter, r *http.Request) { + var creds AuthLoginRequest + dec := json.NewDecoder(r.Body) + if err := dec.Decode(&creds); err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + if creds.Domain != testDomain || + creds.Username != testUsername || + creds.Password != testPassword { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + enc := json.NewEncoder(w) + w.Header().Set("content-type", "application/json") + + h.RLock() + defer h.RUnlock() + if err := enc.Encode(h.tokens); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} + +func (h *horizonAPIMock) refreshHandler(w http.ResponseWriter, r *http.Request) { + var refresh RefreshTokenRequest + dec := json.NewDecoder(r.Body) + if err := dec.Decode(&refresh); err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + h.Lock() + defer h.Unlock() + + if h.tokens.RefreshToken != refresh.RefreshToken { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + enc := json.NewEncoder(w) + w.Header().Set("content-type", "application/json") + + accessToken := AccessToken{ + AccessToken: h.tokens.AccessToken, + } + + if err := enc.Encode(accessToken); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} + +func (h *horizonAPIMock) logoutHandler(w http.ResponseWriter, r *http.Request) { + var refresh RefreshTokenRequest + dec := json.NewDecoder(r.Body) + if err := dec.Decode(&refresh); err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + h.Lock() + defer h.Unlock() + + if refresh.RefreshToken == h.tokens.RefreshToken { + w.WriteHeader(http.StatusOK) + return + } + + w.WriteHeader(http.StatusBadRequest) +} + +func randomToken(n int) string { + rand.Seed(time.Now().Unix()) + + letter := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + b := make([]rune, n) + for i := range b { + b[i] = letter[rand.Intn(len(letter))] + } + return string(b) +} diff --git a/pkg/horizon/testdata/audit_events.golden b/pkg/horizon/testdata/audit_events.golden new file mode 100644 index 000000000..c54700d32 --- /dev/null +++ b/pkg/horizon/testdata/audit_events.golden @@ -0,0 +1,94 @@ +[ + { + "id": 98563, + "type": "REST_AUTH_LOGIN_SUCCESS", + "severity": "AUDIT_SUCCESS", + "module": "Rest", + "machine_dns_name": "Horizon-01.corp.local", + "time": 1627369939733, + "message": "User corp\\administrator has logged in to Horizon Server REST API" + }, + { + "id": 98562, + "user_id": "S-1-5-21-4442515-1634369418-872054540-500", + "type": "VLSI_USERLOGGEDIN", + "severity": "AUDIT_SUCCESS", + "module": "Vlsi", + "machine_dns_name": "Horizon-01.corp.local", + "time": 1627369939490, + "message": "User corp\\administrator has logged in to View Administrator" + }, + { + "id": 98561, + "type": "BROKER_SMALL_MEMORY", + "severity": "WARNING", + "module": "Broker", + "machine_dns_name": "Horizon-02.corp.local", + "time": 1627368981807, + "message": "Broker Horizon-02.corp.local has been detected as being configured with a small amount of physical memory. Please refer to the View Installation Guide." + }, + { + "id": 98558, + "type": "BROKER_DAILY_MAX_APP_USERS", + "severity": "INFO", + "module": "Broker", + "machine_dns_name": "Horizon-01.corp.local", + "time": 1627368903620, + "message": "Over the past 24 hours, the maximum number of users with concurrent application sessions was 0" + }, + { + "id": 98559, + "type": "BROKER_DAILY_MAX_CCU_USERS", + "severity": "INFO", + "module": "Broker", + "machine_dns_name": "Horizon-01.corp.local", + "time": 1627368903620, + "message": "Over the past 24 hours, the maximum number of concurrent connection sessions was 0" + }, + { + "id": 98560, + "type": "BROKER_DAILY_MAX_NU_USERS", + "severity": "INFO", + "module": "Broker", + "machine_dns_name": "Horizon-01.corp.local", + "time": 1627368903620, + "message": "Over the past 24 hours, the maximum number of named user sessions was 23" + }, + { + "id": 98557, + "type": "BROKER_DAILY_MAX_DESKTOP_SESSIONS", + "severity": "INFO", + "module": "Broker", + "machine_dns_name": "Horizon-01.corp.local", + "time": 1627368903617, + "message": "Over the past 24 hours, the maximum number of concurrent desktop sessions was 0" + }, + { + "id": 98556, + "type": "BROKER_SMALL_MEMORY", + "severity": "WARNING", + "module": "Broker", + "machine_dns_name": "Horizon-01.corp.local", + "time": 1627367926447, + "message": "Broker Horizon-01.corp.local has been detected as being configured with a small amount of physical memory. Please refer to the View Installation Guide." + }, + { + "id": 98555, + "type": "REST_AUTH_LOGOUT_SUCCESS", + "severity": "AUDIT_SUCCESS", + "module": "Rest", + "machine_dns_name": "Horizon-01.corp.local", + "time": 1627332342703, + "message": "User corp\\administrator has logged out from Horizon Server REST API" + }, + { + "id": 98554, + "user_id": "S-1-5-21-4442515-1634369418-872054540-500", + "type": "ADMIN_USERLOGGEDOUT", + "severity": "AUDIT_SUCCESS", + "module": "Vlsi", + "machine_dns_name": "Horizon-01.corp.local", + "time": 1627332342687, + "message": "User corp\\administrator has logged out from View Administrator" + } +] diff --git a/pkg/horizon/types.go b/pkg/horizon/types.go new file mode 100644 index 000000000..f54d8a31a --- /dev/null +++ b/pkg/horizon/types.go @@ -0,0 +1,95 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizon + +// generated from https://code-stg.vmware.com/apis/1169/view-rest-api + +// AuthLoginRequest is used to perform a full authentication against the Horizon +// API server +type AuthLoginRequest struct { + // Domain + Domain string `json:"domain"` + // User Name + Username string `json:"username"` + // User password + Password string `json:"password"` +} + +// RefreshTokenRequest is used to get new Access token +type RefreshTokenRequest struct { + RefreshToken string `json:"refresh_token"` +} + +// AccessToken contains the new access token returned from a successful token +// refresh +type AccessToken struct { + // Access Token to be used in API calls. + AccessToken string `json:"access_token"` +} + +// AuthTokens contains authentication details with access and refresh token +type AuthTokens struct { + // Access Token to be used in API calls. + AccessToken string `json:"access_token"` + // Refresh Token to be used to get a new Access token. + RefreshToken string `json:"refresh_token"` +} + +// AuditEventAttributeInfo contains extended event attribute information +type AuditEventAttributeInfo struct { + // Key value pairs representing Extended attributes related to the event. + EventData map[string]interface{} `json:"event_data,omitempty"` + // Unique id representing an event. + ID int64 `json:"id,omitempty"` +} + +// AuditEventSummary contains information about audit events +type AuditEventSummary struct { + // Application Pool associated with this event. Will be unset if there is no + // application association for this event. Supported Filters : 'Equals'. + ApplicationPoolName string `json:"application_pool_name,omitempty"` + // Desktop Pool associated with this event. Will be unset if there is no desktop + // association for this event. Supported Filters : 'Equals'. + DesktopPoolName string `json:"desktop_pool_name,omitempty"` + // Unique id representing an event. Supported Filters : 'Equals'. + ID int64 `json:"id,omitempty"` + // FQDN of the machine in the Pod that has logged this event. Supported Filters + // : 'Equals'. + MachineDNSName string `json:"machine_dns_name,omitempty"` + // Machine associated with this event. Will be unset if there is no machine + // association for this event. Supported Filters : 'Equals'. + MachineID string `json:"machine_id,omitempty"` + // Audit event message. + Message string `json:"message,omitempty"` + // Horizon component that has logged this event. Supported Filters : 'Equals'. + Module string `json:"module,omitempty"` + // Severity type of the event. Supported Filters : 'Equals'. * INFO: Audit event + // is of INFO severity. * WARNING: Audit event is of WARNING severity * ERROR: + // Audit event is of ERROR severity * AUDIT_SUCCESS: Audit event is of + // AUDIT_SUCCESS severity * AUDIT_FAIL: Audit event is of AUDIT_FAIL severity * + // UNKNOWN: Not able to identify severity + Severity string `json:"severity,omitempty"` + // Time at which the event occurred. Supported Filters : 'Equals'. + Time int64 `json:"time,omitempty"` + // Event name that corresponds to an item in the message catalog. Supported + // Filters : 'Equals'. + Type string `json:"type,omitempty"` + // Sid of the user associated with this event. Supported Filters : 'Equals'. + UserID string `json:"user_id,omitempty"` +} + +// BetweenFilter is a range filter. It can be used to filter on int64 +// timestamps. +type BetweenFilter struct { + Type string `json:"type,omitempty"` + FromValue interface{} `json:"fromValue,omitempty"` + Name string `json:"name,omitempty"` + ToValue interface{} `json:"toValue,omitempty"` +} + +// Timestamp is time since unix epoch (UTC) in milliseconds (as defined by +// Horizon spec) +type Timestamp int64 diff --git a/pkg/reconciler/horizonsource/controller.go b/pkg/reconciler/horizonsource/controller.go new file mode 100644 index 000000000..0478bc61e --- /dev/null +++ b/pkg/reconciler/horizonsource/controller.go @@ -0,0 +1,76 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizonsource + +import ( + "context" + "time" + + "knative.dev/pkg/metrics" + + "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + + "github.com/kelseyhightower/envconfig" + "k8s.io/client-go/tools/cache" + + "knative.dev/pkg/configmap" + "knative.dev/pkg/controller" + "knative.dev/pkg/logging" + "knative.dev/pkg/resolver" + + kubeclient "knative.dev/pkg/client/injection/kube/client" + deploymentinformer "knative.dev/pkg/client/injection/kube/informers/apps/v1/deployment" + + sainformer "knative.dev/pkg/client/injection/kube/informers/core/v1/serviceaccount" + + horizonsourceinformer "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/informers/sources/v1alpha1/horizonsource" + "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource" +) + +const ( + resyncPeriod = time.Second * 10 +) + +// NewController initializes the controller and is called by the generated code +// Registers event handlers to enqueue events +func NewController(ctx context.Context, cmw configmap.Watcher) *controller.Impl { + ctx = controller.WithResyncPeriod(ctx, resyncPeriod) + + r := &Reconciler{ + loggingContext: ctx, + kclient: kubeclient.Get(ctx), + depl: &DeploymentReconciler{KubeClientSet: kubeclient.Get(ctx)}, + sa: &ServiceAccountReconciler{KubeClientSet: kubeclient.Get(ctx)}, + } + + if err := envconfig.Process("", r); err != nil { + logging.FromContext(ctx).Panicf("required environment variable is not defined: %v", err) + } + + impl := horizonsource.NewImpl(ctx, r) + r.sinkResolver = resolver.NewURIResolverFromTracker(ctx, impl.Tracker) + + horizonSourceInformer := horizonsourceinformer.Get(ctx) + saInformer := sainformer.Get(ctx) + deploymentInformer := deploymentinformer.Get(ctx) + + horizonSourceInformer.Informer().AddEventHandler(controller.HandleAll(impl.Enqueue)) + + saInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ + FilterFunc: controller.FilterController(&v1alpha1.HorizonSource{}), + Handler: controller.HandleAll(impl.EnqueueControllerOf), + }) + + deploymentInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ + FilterFunc: controller.FilterController(&v1alpha1.HorizonSource{}), + Handler: controller.HandleAll(impl.EnqueueControllerOf), + }) + + cmw.Watch(logging.ConfigMapName(), r.UpdateFromLoggingConfigMap) + cmw.Watch(metrics.ConfigMapName(), r.UpdateFromMetricsConfigMap) + + return impl +} diff --git a/pkg/reconciler/horizonsource/deployment.go b/pkg/reconciler/horizonsource/deployment.go new file mode 100644 index 000000000..d387b6226 --- /dev/null +++ b/pkg/reconciler/horizonsource/deployment.go @@ -0,0 +1,151 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizonsource + +import ( + "context" + "fmt" + "sort" + + // k8s.io imports + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" + + "knative.dev/pkg/kmeta" + "knative.dev/pkg/logging" + pkgreconciler "knative.dev/pkg/reconciler" + + "go.uber.org/zap" +) + +// newDeploymentCreated makes a reconciler event with event type Normal, and +// reason DeploymentCreated. +func newDeploymentCreated(namespace, name string) pkgreconciler.Event { + return pkgreconciler.NewEvent(corev1.EventTypeNormal, "DeploymentCreated", "created deployment: \"%s/%s\"", namespace, name) +} + +// newDeploymentFailed makes a reconciler event with event type Warning, and +// reason DeploymentFailed. +func newDeploymentFailed(namespace, name string, err error) pkgreconciler.Event { + return pkgreconciler.NewEvent(corev1.EventTypeWarning, "DeploymentFailed", "failed to create deployment: \"%s/%s\", %w", namespace, name, err) +} + +// newDeploymentUpdated makes a reconciler event with event type Normal, and +// reason DeploymentUpdated. +func newDeploymentUpdated(namespace, name string) pkgreconciler.Event { + return pkgreconciler.NewEvent(corev1.EventTypeNormal, "DeploymentUpdated", "updated deployment: \"%s/%s\"", namespace, name) +} + +type DeploymentReconciler struct { + KubeClientSet kubernetes.Interface +} + +// ReconcileDeployment reconciles deployment resource (adapter) for HorizonSource +func (r *DeploymentReconciler) ReconcileDeployment(ctx context.Context, owner kmeta.OwnerRefable, expected *appsv1.Deployment) (*appsv1.Deployment, pkgreconciler.Event) { + namespace := owner.GetObjectMeta().GetNamespace() + + ra, err := r.KubeClientSet.AppsV1().Deployments(namespace).Get(ctx, expected.Name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + ra, err = r.KubeClientSet.AppsV1().Deployments(namespace).Create(ctx, expected, metav1.CreateOptions{}) + if err != nil { + return nil, newDeploymentFailed(expected.Namespace, expected.Name, err) + } + return ra, newDeploymentCreated(ra.Namespace, ra.Name) + } + return nil, fmt.Errorf("error getting receive adapter %q: %v", expected.Name, err) + } + + if !metav1.IsControlledBy(ra, owner.GetObjectMeta()) { + return nil, fmt.Errorf("deployment %q is not owned by %s %q", + ra.Name, owner.GetGroupVersionKind().Kind, owner.GetObjectMeta().GetName()) + } + + if podSpecSync(ctx, expected.Spec.Template.Spec, ra.Spec.Template.Spec) { + logging.FromContext(ctx).Debugw("updating receive adapter: pod template spec out of sync") + + ra.Spec.Template.Spec = expected.Spec.Template.Spec + ra, err = r.KubeClientSet.AppsV1().Deployments(namespace).Update(ctx, ra, metav1.UpdateOptions{}) + if err != nil { + return ra, err + } + return ra, newDeploymentUpdated(ra.Namespace, ra.Name) + } + + logging.FromContext(ctx).Debugw("reusing existing receive adapter", zap.Any("receiveAdapter", ra)) + return ra, nil +} + +func (r *DeploymentReconciler) FindOwned(ctx context.Context, owner kmeta.OwnerRefable, selector labels.Selector) (*appsv1.Deployment, error) { + dl, err := r.KubeClientSet.AppsV1().Deployments(owner.GetObjectMeta().GetNamespace()).List(ctx, metav1.ListOptions{ + LabelSelector: selector.String(), + }) + if err != nil { + logging.FromContext(ctx).Error("Unable to list deployments: %v", zap.Error(err)) + return nil, err + } + for i := range dl.Items { + if metav1.IsControlledBy(&dl.Items[i], owner.GetObjectMeta()) { + return &dl.Items[i], nil + } + } + return nil, apierrors.NewNotFound(schema.GroupResource{}, "") +} + +func getContainer(name string, spec corev1.PodSpec) (int, *corev1.Container) { + for i, c := range spec.Containers { + if c.Name == name { + return i, &c + } + } + return -1, nil +} + +// Returns true if an update is needed. +func podSpecSync(_ context.Context, expected corev1.PodSpec, now corev1.PodSpec) bool { + old := *now.DeepCopy() + syncContainers(expected, now) + return !equality.Semantic.DeepEqual(old, now) +} + +func syncContainers(expected corev1.PodSpec, now corev1.PodSpec) { + // got needs all of the containers that want as, but it is allowed to have more. + for _, ec := range expected.Containers { + n, nc := getContainer(ec.Name, now) + if nc == nil { + now.Containers = append(now.Containers, ec) + continue + } + if nc.Image != ec.Image { + now.Containers[n].Image = ec.Image + } + + // copy and sort envs to avoid reconcile when only env order is different + expEnvs := make([]corev1.EnvVar, len(ec.Env)) + nowEnvs := make([]corev1.EnvVar, len(nc.Env)) + + copy(expEnvs, ec.Env) + copy(nowEnvs, nc.Env) + + sort.Slice(expEnvs, func(i, j int) bool { + return expEnvs[i].Name < expEnvs[j].Name + }) + + sort.Slice(nowEnvs, func(i, j int) bool { + return nowEnvs[i].Name < nowEnvs[j].Name + }) + + if !equality.Semantic.DeepEqual(expEnvs, nowEnvs) { + now.Containers[n].Env = ec.Env + } + } +} diff --git a/pkg/reconciler/horizonsource/horizonsource.go b/pkg/reconciler/horizonsource/horizonsource.go new file mode 100644 index 000000000..f6e64e384 --- /dev/null +++ b/pkg/reconciler/horizonsource/horizonsource.go @@ -0,0 +1,173 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizonsource + +import ( + "context" + "errors" + "fmt" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + "knative.dev/pkg/metrics" + + // k8s.io imports + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + // knative.dev/pkg imports + "knative.dev/pkg/logging" + pkgreconciler "knative.dev/pkg/reconciler" + "knative.dev/pkg/resolver" + + // knative.dev/eventing imports + sourcesv1 "knative.dev/eventing/pkg/apis/sources/v1" + + "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + "github.com/vmware-tanzu/sources-for-knative/pkg/client/injection/reconciler/sources/v1alpha1/horizonsource" + "github.com/vmware-tanzu/sources-for-knative/pkg/reconciler/horizonsource/resources" +) + +const ( + component = "horizonsource" +) + +// Reconciler reconciles a HorizonSource object +type Reconciler struct { + ReceiveAdapterImage string `envconfig:"HORIZON_SOURCE_RA_IMAGE" required:"true"` + + kclient kubernetes.Interface + + // reconcilers + depl *DeploymentReconciler + sa *ServiceAccountReconciler + + loggingContext context.Context + loggingConfig *logging.Config + metricsConfig *metrics.ExporterOptions + + sinkResolver *resolver.URIResolver +} + +// Check that our Reconciler implements Interface +var _ horizonsource.Interface = (*Reconciler)(nil) + +// ReconcileKind implements Interface.ReconcileKind. +func (r *Reconciler) ReconcileKind(ctx context.Context, src *v1alpha1.HorizonSource) pkgreconciler.Event { + ctx = sourcesv1.WithURIResolver(ctx, r.sinkResolver) + + src.Status.InitializeConditions() + + if err := src.Spec.Sink.Validate(ctx); err != nil { + src.Status.MarkNoSink("SinkMissing", "") + return fmt.Errorf("spec.sink missing") + } + + dest := src.Spec.Sink.DeepCopy() + if dest.Ref != nil { + if dest.Ref.Namespace == "" { + dest.Ref.Namespace = src.GetNamespace() + } + } + sinkURI, err := r.sinkResolver.URIFromDestinationV1(ctx, *dest, src) + if err != nil { + src.Status.MarkNoSink("NotFound", "") + return fmt.Errorf("getting sink URI: %v", err) + } + src.Status.MarkSink(sinkURI) + + _, err = r.kclient.CoreV1().Secrets(src.Namespace).Get(ctx, src.Spec.SecretRef.Name, metav1.GetOptions{}) + if err != nil { + logging.FromContext(ctx).Errorw("returning because required secret not found", zap.String("secret", src.Spec.SecretRef.Name), zap.Error(err)) + return err + } + + labels := resources.Labels(src.Name) + + // create serviceAccount + _, err = r.sa.ReconcileServiceAccount(ctx, src, labels) + if err != nil { + logging.FromContext(ctx).Errorw("returning because of event from ReconcileServiceAccount", zap.Error(err)) + return err + } + + loggingConfig, err := logging.ConfigToJSON(r.loggingConfig) + if err != nil { + logging.FromContext(ctx).Error("returning because cannot convert logging config to JSON", zap.Error(err)) + return err + } + + metricsConfig, err := metrics.OptionsToJSON(r.metricsConfig) + if err != nil { + logging.FromContext(ctx).Error("returning because cannot convert metrics config to JSON", zap.Error(err)) + return err + } + + // create adapter + args := resources.ReceiveAdapterArgs{ + Image: r.ReceiveAdapterImage, + Labels: labels, + Source: src, + SinkURI: sinkURI.String(), + LoggingConfig: loggingConfig, + MetricsConfig: metricsConfig, + } + adapter, err := resources.NewReceiveAdapter(ctx, &args) + if err != nil { + logging.FromContext(ctx).Errorw("returning because adapter could not be configured", zap.Error(err)) + return err + } + + ra, err := r.depl.ReconcileDeployment(ctx, src, adapter) + if ra != nil { + src.Status.PropagateDeploymentAvailability(ra) + } + + if err != nil { + // ignore normal reconcile events + var reconcileErr *pkgreconciler.ReconcilerEvent + if errors.As(err, &reconcileErr) { + if reconcileErr.EventType == corev1.EventTypeNormal { + return nil + } + logging.FromContext(ctx).Errorw("returning because of non-normal event from ReconcileDeployment", zap.Error(err)) + return err + } + + logging.FromContext(ctx).Errorw("returning because of reconcile error", zap.Error(err)) + return err + } + + return nil +} + +func (r *Reconciler) UpdateFromLoggingConfigMap(cfg *corev1.ConfigMap) { + if cfg != nil { + delete(cfg.Data, "_example") + } + + logcfg, err := logging.NewConfigFromConfigMap(cfg) + if err != nil { + logging.FromContext(r.loggingContext).Warn("failed to create logging config from configmap", zap.String("cfg.Name", cfg.Name)) + return + } + + r.loggingConfig = logcfg + logging.FromContext(r.loggingContext).Info("Update from logging ConfigMap", zap.Any("configMap", cfg)) +} + +func (r *Reconciler) UpdateFromMetricsConfigMap(cfg *corev1.ConfigMap) { + if cfg != nil { + delete(cfg.Data, "_example") + } + + r.metricsConfig = &metrics.ExporterOptions{ + Domain: metrics.Domain(), + Component: component, + ConfigMap: cfg.Data, + } + logging.FromContext(r.loggingContext).Info("Update from metrics ConfigMap", zap.Any("configMap", cfg)) +} diff --git a/pkg/reconciler/horizonsource/resources/adapter.go b/pkg/reconciler/horizonsource/resources/adapter.go new file mode 100644 index 000000000..3bd809824 --- /dev/null +++ b/pkg/reconciler/horizonsource/resources/adapter.go @@ -0,0 +1,154 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package resources + +import ( + "context" + "encoding/json" + "fmt" + + v1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/kmeta" + "knative.dev/pkg/ptr" + + "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + "github.com/vmware-tanzu/sources-for-knative/pkg/horizon" + "github.com/vmware-tanzu/sources-for-knative/pkg/reconciler/horizonsource/resources/names" +) + +// ReceiveAdapterArgs are the arguments needed to create a Horizon source Receive Adapter. +// Every field is required. +type ReceiveAdapterArgs struct { + Image string + Labels map[string]string + Source *v1alpha1.HorizonSource + SinkURI string + LoggingConfig string + MetricsConfig string +} + +// NewReceiveAdapter generates the Receive Adapter Deployment for Horizon +// sources +func NewReceiveAdapter(ctx context.Context, args *ReceiveAdapterArgs) (*v1.Deployment, error) { + env, err := makeEnv(ctx, args) + if err != nil { + return nil, err + } + + return &v1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: args.Source.Namespace, + Name: names.NewAdapterName(args.Source.Name), + Labels: args.Labels, + OwnerReferences: []metav1.OwnerReference{ + *kmeta.NewControllerRef(args.Source), + }, + }, + Spec: v1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: args.Labels, + }, + Replicas: ptr.Int32(1), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: args.Labels, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: args.Source.Spec.ServiceAccountName, + Containers: []corev1.Container{ + { + Name: "adapter", + Image: args.Image, + Env: env, + // TODO (@mgasch): add resources + // Resources: corev1.ResourceRequirements{},, + VolumeMounts: []corev1.VolumeMount{ + { + Name: args.Source.Spec.SecretRef.Name, + ReadOnly: true, + MountPath: horizon.DefaultSecretMountPath, + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: args.Source.Spec.SecretRef.Name, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: args.Source.Spec.SecretRef.Name, + }, + }, + }, + }, + }, + }, + Strategy: v1.DeploymentStrategy{ + // terminate existing instance before creating a new one to reduce chance of + // multiple source adapters sending events when changing log levels and running + // kubectl rollout restart + Type: v1.RecreateDeploymentStrategyType, + }, + }, + }, nil +} + +func makeEnv(ctx context.Context, args *ReceiveAdapterArgs) ([]corev1.EnvVar, error) { + var ceOverrides string + if args.Source.Spec.CloudEventOverrides != nil { + if co, err := json.Marshal(args.Source.Spec.SourceSpec.CloudEventOverrides); err != nil { + return nil, + fmt.Errorf("failed to marshal CloudEventOverrides into JSON for %+v: %w", args.Source, err) + } else if len(co) > 0 { + ceOverrides = string(co) + } + } + + return []corev1.EnvVar{ + { + Name: "HORIZON_URL", + Value: args.Source.Spec.Address.String(), + }, + { + Name: "HORIZON_INSECURE", + Value: fmt.Sprintf("%t", args.Source.Spec.SkipTLSVerify), + }, + { + Name: "METRICS_DOMAIN", + Value: "knative.dev/eventing", + }, + { + Name: "K_CE_OVERRIDES", + Value: ceOverrides, + }, + { + Name: "SINK_URI", + Value: args.SinkURI, + }, + { + Name: "K_SINK", + Value: args.SinkURI, + }, + { + Name: "NAME", + Value: args.Source.Name, + }, + { + Name: "NAMESPACE", + Value: args.Source.Namespace, + }, + { + Name: "K_LOGGING_CONFIG", + Value: args.LoggingConfig, + }, + { + Name: "K_METRICS_CONFIG", + Value: args.MetricsConfig, + }, + }, nil +} diff --git a/pkg/reconciler/horizonsource/resources/labels.go b/pkg/reconciler/horizonsource/resources/labels.go new file mode 100644 index 000000000..1b42092af --- /dev/null +++ b/pkg/reconciler/horizonsource/resources/labels.go @@ -0,0 +1,19 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package resources + +const ( + // controllerAgentName is the string used by this controller to identify + // itself when creating events. + controllerAgentName = "horizon-source-controller" +) + +func Labels(name string) map[string]string { + return map[string]string{ + "knative-eventing-source": controllerAgentName, + "knative-eventing-source-name": name, + } +} diff --git a/pkg/reconciler/horizonsource/resources/names/names.go b/pkg/reconciler/horizonsource/resources/names/names.go new file mode 100644 index 000000000..14c2e7963 --- /dev/null +++ b/pkg/reconciler/horizonsource/resources/names/names.go @@ -0,0 +1,17 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package names + +import ( + "knative.dev/pkg/kmeta" +) + +// (@mgasch) not using source prefixes for now +// const prefix = "horizon-source" + +func NewAdapterName(source string) string { + return kmeta.ChildName(source, "-adapter") +} diff --git a/pkg/reconciler/horizonsource/resources/names/names_test.go b/pkg/reconciler/horizonsource/resources/names/names_test.go new file mode 100644 index 000000000..2efd600f6 --- /dev/null +++ b/pkg/reconciler/horizonsource/resources/names/names_test.go @@ -0,0 +1,43 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package names + +import ( + "testing" +) + +func TestNewAdapterName(t *testing.T) { + type args struct { + source string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "source name within 63 char limit", + args: args{ + source: "horizon-01", + }, + want: "horizon-01-adapter", + }, + { + name: "source name exceeds 63 char limit", + args: args{ + source: "horizon-01-7c6e3f71-7c98-43dc-b783-72bcf0103970-way-tooooooooooooooo-long", + }, + want: "horizon-01-7c6e3f71-7c9f88c636a5f0781b807c2771ff73668ac-adapter", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewAdapterName(tt.args.source); got != tt.want { + t.Errorf("NewAdapterName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/reconciler/horizonsource/resources/serviceaccount.go b/pkg/reconciler/horizonsource/resources/serviceaccount.go new file mode 100644 index 000000000..8b806aff3 --- /dev/null +++ b/pkg/reconciler/horizonsource/resources/serviceaccount.go @@ -0,0 +1,27 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package resources + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/kmeta" + + "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" +) + +func NewServiceAccount(src *v1alpha1.HorizonSource, labels map[string]string) *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: src.Spec.ServiceAccountName, + Namespace: src.Namespace, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{ + *kmeta.NewControllerRef(src), + }, + }, + } +} diff --git a/pkg/reconciler/horizonsource/serviceaccount.go b/pkg/reconciler/horizonsource/serviceaccount.go new file mode 100644 index 000000000..a90c960e2 --- /dev/null +++ b/pkg/reconciler/horizonsource/serviceaccount.go @@ -0,0 +1,58 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizonsource + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + pkgreconciler "knative.dev/pkg/reconciler" + + "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + "github.com/vmware-tanzu/sources-for-knative/pkg/reconciler/horizonsource/resources" +) + +// newServiceAccountCreated makes a reconciler event with event type Normal, and +// reason RoleCreated. +func newServiceAccountCreated(namespace, name string) pkgreconciler.Event { + return pkgreconciler.NewEvent(corev1.EventTypeNormal, "ServiceAccountCreated", "created service account: \"%s/%s\"", namespace, name) +} + +// newServiceAccountFailed makes a reconciler event with event type Warning, and +// reason RoleFailed. +func newServiceAccountFailed(namespace, name string, err error) pkgreconciler.Event { + return pkgreconciler.NewEvent(corev1.EventTypeWarning, "ServiceAccountFailed", "failed to create service account: \"%s/%s\", %w", namespace, name, err) +} + +type ServiceAccountReconciler struct { + KubeClientSet kubernetes.Interface +} + +// ReconcileServiceAccount reconciles service account resource for HorizonSource +func (s *ServiceAccountReconciler) ReconcileServiceAccount(ctx context.Context, src *v1alpha1.HorizonSource, labels map[string]string) (*corev1.ServiceAccount, pkgreconciler.Event) { + namespace := src.Namespace + saName := src.Spec.ServiceAccountName + + sa, err := s.KubeClientSet.CoreV1().ServiceAccounts(namespace).Get(ctx, saName, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + sa, err = s.KubeClientSet.CoreV1().ServiceAccounts(namespace).Create(ctx, resources.NewServiceAccount(src, labels), metav1.CreateOptions{}) + if err != nil { + return nil, newServiceAccountFailed(src.Namespace, saName, err) + } + return sa, newServiceAccountCreated(sa.Namespace, sa.Name) + } + return nil, fmt.Errorf("error getting service account %q: %v", saName, err) + } + + // TODO (@mgasch): handle updates + + return sa, nil +} diff --git a/pkg/reconciler/horizonsource/serviceaccount_test.go b/pkg/reconciler/horizonsource/serviceaccount_test.go new file mode 100644 index 000000000..46dd53de3 --- /dev/null +++ b/pkg/reconciler/horizonsource/serviceaccount_test.go @@ -0,0 +1,94 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package horizonsource + +import ( + "context" + "reflect" + "testing" + + "github.com/vmware-tanzu/sources-for-knative/pkg/apis/sources/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + pkgreconciler "knative.dev/pkg/reconciler" +) + +func Test_newServiceAccountCreated(t *testing.T) { + type args struct { + namespace string + name string + } + tests := []struct { + name string + args args + want pkgreconciler.Event + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := newServiceAccountCreated(tt.args.namespace, tt.args.name); !reflect.DeepEqual(got, tt.want) { + t.Errorf("newServiceAccountCreated() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_newServiceAccountFailed(t *testing.T) { + type args struct { + namespace string + name string + err error + } + tests := []struct { + name string + args args + want pkgreconciler.Event + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := newServiceAccountFailed(tt.args.namespace, tt.args.name, tt.args.err); !reflect.DeepEqual(got, tt.want) { + t.Errorf("newServiceAccountFailed() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestServiceAccountReconciler_ReconcileServiceAccount(t *testing.T) { + type fields struct { + KubeClientSet kubernetes.Interface + } + type args struct { + ctx context.Context + src *v1alpha1.HorizonSource + labels map[string]string + } + tests := []struct { + name string + fields fields + args args + want *corev1.ServiceAccount + want1 pkgreconciler.Event + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &ServiceAccountReconciler{ + KubeClientSet: tt.fields.KubeClientSet, + } + got, got1 := s.ReconcileServiceAccount(tt.args.ctx, tt.args.src, tt.args.labels) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ServiceAccountReconciler.ReconcileServiceAccount() got = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(got1, tt.want1) { + t.Errorf("ServiceAccountReconciler.ReconcileServiceAccount() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} diff --git a/pkg/reconciler/vspheresource/resources/deployment.go b/pkg/reconciler/vspheresource/resources/deployment.go index f65dfbcf6..bf18be0c6 100644 --- a/pkg/reconciler/vspheresource/resources/deployment.go +++ b/pkg/reconciler/vspheresource/resources/deployment.go @@ -60,6 +60,7 @@ func MakeDeployment(ctx context.Context, vms *v1alpha1.VSphereSource, args Adapt Name: names.Deployment(vms), Namespace: vms.Namespace, OwnerReferences: []metav1.OwnerReference{*kmeta.NewControllerRef(vms)}, + Labels: labels, }, Spec: appsv1.DeploymentSpec{ Replicas: ptr.Int32(1), diff --git a/pkg/reconciler/vspheresource/resources/names/names.go b/pkg/reconciler/vspheresource/resources/names/names.go index 52cd5f6c0..9f98306c9 100644 --- a/pkg/reconciler/vspheresource/resources/names/names.go +++ b/pkg/reconciler/vspheresource/resources/names/names.go @@ -12,7 +12,7 @@ import ( ) func Deployment(vms *v1alpha1.VSphereSource) string { - return kmeta.ChildName(vms.Name, "-deployment") + return kmeta.ChildName(vms.Name, "-adapter") } func VSphereBinding(vms *v1alpha1.VSphereSource) string { diff --git a/pkg/reconciler/vspheresource/resources/names/names_test.go b/pkg/reconciler/vspheresource/resources/names/names_test.go index eb0d3deff..5adf3380d 100644 --- a/pkg/reconciler/vspheresource/resources/names/names_test.go +++ b/pkg/reconciler/vspheresource/resources/names/names_test.go @@ -27,7 +27,7 @@ func TestNames(t *testing.T) { }, }, f: Deployment, - want: "ffffffffffffffffffff105d7597f637e83cc711605ac3ea4957-deployment", + want: "fffffffffffffffffffffff105d7597f637e83cc711605ac3ea4957-adapter", }, { name: "Deployment long enough", vss: &v1alpha1.VSphereSource{ @@ -36,7 +36,7 @@ func TestNames(t *testing.T) { }, }, f: Deployment, - want: strings.Repeat("f", 52) + "-deployment", + want: strings.Repeat("f", 52) + "-adapter", }, { name: "Deployment", vss: &v1alpha1.VSphereSource{ @@ -45,7 +45,7 @@ func TestNames(t *testing.T) { }, }, f: Deployment, - want: "foo-deployment", + want: "foo-adapter", }, { name: "vspherebinding", vss: &v1alpha1.VSphereSource{ diff --git a/pkg/vsphere/adapter.go b/pkg/vsphere/adapter.go index 5e71ccdbd..38c00180a 100644 --- a/pkg/vsphere/adapter.go +++ b/pkg/vsphere/adapter.go @@ -200,7 +200,7 @@ func (a *vAdapter) readEvents(ctx context.Context, c *event.HistoryCollector) er if len(events) == 0 { delay := bOff.Duration() - logger.Debugw("no new events, backing off", zap.String("delaySeconds", delay.String())) + logger.Debugw("backing off retrieving events: no new events received", zap.Duration("backoffSeconds", delay)) time.Sleep(delay) continue } diff --git a/samples/README.md b/samples/README.md index a190e0dd0..20803d340 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,7 +1,8 @@ -## VSphere Source and Binding samples +## VMware Sources and Binding samples - [Using `VSphereSource` with `vcsim`](./vcsim/README.md) - [Using `VSphereBinding` with `govc`](./govc/README.md) - [Using `VSphereBinding` with `PowerCLI`](./powercli/README.md) - [Using `VSphereSource` and `VSphereBinding` to tag new VMs](./tag-new-vms/README.md) - [Using `VSphereBinding` to build a `PowerCLI` "Cloud Shell"](./cloud-power-shell/README.md) +- [Using `HorizonSource`](./horizon/README.md) diff --git a/samples/horizon/README.md b/samples/horizon/README.md new file mode 100644 index 000000000..a709cf24b --- /dev/null +++ b/samples/horizon/README.md @@ -0,0 +1,97 @@ +## `HorizonSource` Example + +To create a `HorizonSource` the Horizon server API version must be at least +`2106`. There are over 850+ events that are available and for a complete list, +please take a look [here](https://github.com/lamw/horizon-event-mapping/). + + +### Configure the Source + +Modify the `horizon-source.yml` according to your environment. + +`address` is the HTTPs endpoint of the Horizon API server. To skip TLS and +certificate verification, set `skipTLSVerify` to `true`. + +Change the values under `sink` to match your Knative Eventing environment. + +If the specified `serviceAccountName` does not exist, it will be created +automatically. + +```yaml +apiVersion: sources.tanzu.vmware.com/v1alpha1 +kind: HorizonSource +metadata: + name: horizon-example +spec: + sink: + ref: + apiVersion: eventing.knative.dev/v1 + kind: Broker + name: example-broker + namespace: default + address: https://horizon.server.example.com + skipTLSVerify: false + secretRef: + name: horizon-credentials + serviceAccountName: horizon-source-sa +``` + +### Configure authentication + +Create a Kubernetes `Secret` as per the name under `secretRef` in the +`HorizonSource` above which holds the required Horizon credentials. `domain`, +`username` and `password` are required fields. Replace the field values +accordingly. + +```shell +kubectl create secret generic horizon-credentials --from-literal=domain="example.com" --from-literal=username="horizon-source-account" --from-literal=password='ReplaceMe' +``` + +### Deploy the Source + +Finally, deploy the `HorizonSource`. + +You should see a new deployment with the name `horizon-example-adapter` coming +up in the specified namespace (here `default`). + + +```shell +kubectl create -f horizon-source.yml + +# wait for the deployment to become ready +kubectl wait --timeout=3m --for=condition=Available deploy/horizon-example-adapter +deployment.apps/horizon-example-adapter condition met +``` + +### Enable verbose (debug) logging + +By default, each `HorizonSource` uses the `info` level for logging. + +```shell +kubectl logs deploy/horizon-example-adapter +{"level":"warn","ts":"2022-07-05T09:59:02.701Z","logger":"horizon-source-adapter","caller":"v2/config.go:185","msg":"Tracing configuration is invalid, using the no-op default{error 26 0 empty json tracing config}","commit":"01ea50f"} +{"level":"warn","ts":"2022-07-05T09:59:02.701Z","logger":"horizon-source-adapter","caller":"v2/config.go:178","msg":"Sink timeout configuration is invalid, default to -1 (no timeout)","commit":"01ea50f"} +{"level":"warn","ts":"2022-07-05T09:59:02.701Z","logger":"horizon-source-adapter","caller":"horizon/horizon.go:130","msg":"using potentially insecure connection to Horizon API server","commit":"01ea50f","address":"https://horizon.server.example.com","insecure":true} +{"level":"info","ts":"2022-07-05T09:59:04.140Z","logger":"horizon-source-adapter","caller":"horizon/adapter.go:97","msg":"starting horizon source adapter","commit":"01ea50f","source":"https://horizon.server.example.com","pollIntervalSeconds":1} +``` + +To increase verbosity, update the logging configuration for the VMware Sources +and then perform a rolling restart of the `HorizonSource` adapter for the +logging changes to take effect. + +```shell +# update general logging configuration +kubectl -n vmware-sources edit cm config-logging +``` + +A new window opens with an interactive editor. + +Change the JSON line `"level": "info"` to `"level": "debug"`. Save and exit the +editor. + +Perform a rolling restart of the running `HorizonSource`. + +```shell +kubectl rollout restart deployment/horizon-example-adapter +``` + diff --git a/samples/horizon/horizon-source.yml b/samples/horizon/horizon-source.yml new file mode 100644 index 000000000..308aa2105 --- /dev/null +++ b/samples/horizon/horizon-source.yml @@ -0,0 +1,16 @@ +apiVersion: sources.tanzu.vmware.com/v1alpha1 +kind: HorizonSource +metadata: + name: horizon-example +spec: + sink: + ref: + apiVersion: eventing.knative.dev/v1 + kind: Broker + name: example-broker + namespace: default + address: https://horizon.server.example.com + skipTLSVerify: false + secretRef: + name: horizon-credentials + serviceAccountName: horizon-source-sa diff --git a/samples/tag-new-vms/source.yaml b/samples/tag-new-vms/source.yaml index 4a6e3178f..509d33f9e 100644 --- a/samples/tag-new-vms/source.yaml +++ b/samples/tag-new-vms/source.yaml @@ -1,14 +1,13 @@ apiVersion: sources.tanzu.vmware.com/v1alpha1 kind: VSphereSource metadata: - name: vcsim-to-broker + name: vcsim-to-broker spec: sink: ref: - apiVersion: eventing.knative.dev/v1alpha1 + apiVersion: eventing.knative.dev/v1 kind: Broker name: default - address: https://vcsim.default.svc.cluster.local skipTLSVerify: true secretRef: diff --git a/samples/vcsim/source.yaml b/samples/vcsim/source.yaml index abcfca032..1efef95f5 100644 --- a/samples/vcsim/source.yaml +++ b/samples/vcsim/source.yaml @@ -1,15 +1,14 @@ apiVersion: sources.tanzu.vmware.com/v1alpha1 kind: VSphereSource metadata: - name: vcsim + name: vcsim spec: - sink: - ref: - apiVersion: serving.knative.dev/v1 - kind: Service - name: sockeye - - address: https://vcsim.default.svc.cluster.local - skipTLSVerify: true - secretRef: - name: vsphere-credentials + sink: + ref: + apiVersion: serving.knative.dev/v1 + kind: Service + name: sockeye + address: https://vcsim.default.svc.cluster.local + skipTLSVerify: true + secretRef: + name: vsphere-credentials diff --git a/third_party/VENDOR-LICENSE/github.com/benbjohnson/clock/LICENSE b/third_party/VENDOR-LICENSE/github.com/benbjohnson/clock/LICENSE new file mode 100644 index 000000000..ce212cb1c --- /dev/null +++ b/third_party/VENDOR-LICENSE/github.com/benbjohnson/clock/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ben Johnson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/VENDOR-LICENSE/github.com/go-resty/resty/v2/LICENSE b/third_party/VENDOR-LICENSE/github.com/go-resty/resty/v2/LICENSE new file mode 100644 index 000000000..27326a653 --- /dev/null +++ b/third_party/VENDOR-LICENSE/github.com/go-resty/resty/v2/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2021 Jeevanandam M., https://myjeeva.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/go-resty/resty/v2/.gitignore b/vendor/github.com/go-resty/resty/v2/.gitignore new file mode 100644 index 000000000..9e856bd48 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/.gitignore @@ -0,0 +1,30 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +coverage.out +coverage.txt + +# Exclude intellij IDE folders +.idea/* diff --git a/vendor/github.com/go-resty/resty/v2/LICENSE b/vendor/github.com/go-resty/resty/v2/LICENSE new file mode 100644 index 000000000..27326a653 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2021 Jeevanandam M., https://myjeeva.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/go-resty/resty/v2/README.md b/vendor/github.com/go-resty/resty/v2/README.md new file mode 100644 index 000000000..8ec651828 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/README.md @@ -0,0 +1,906 @@ +

+

Resty

+

Simple HTTP and REST client library for Go (inspired by Ruby rest-client)

+

Features section describes in detail about Resty capabilities

+

+

+

Build Status Code Coverage Go Report Card Release Version GoDoc License Mentioned in Awesome Go

+

+

+

Resty Communication Channels

+

Chat on Gitter - Resty Community Twitter @go_resty

+

+ +## News + + * v2.7.0 [released](https://github.com/go-resty/resty/releases/tag/v2.7.0) and tagged on Nov 03, 2021. + * v2.0.0 [released](https://github.com/go-resty/resty/releases/tag/v2.0.0) and tagged on Jul 16, 2019. + * v1.12.0 [released](https://github.com/go-resty/resty/releases/tag/v1.12.0) and tagged on Feb 27, 2019. + * v1.0 released and tagged on Sep 25, 2017. - Resty's first version was released on Sep 15, 2015 then it grew gradually as a very handy and helpful library. Its been a two years since first release. I'm very thankful to Resty users and its [contributors](https://github.com/go-resty/resty/graphs/contributors). + +## Features + + * GET, POST, PUT, DELETE, HEAD, PATCH, OPTIONS, etc. + * Simple and chainable methods for settings and request + * [Request](https://pkg.go.dev/github.com/go-resty/resty/v2#Request) Body can be `string`, `[]byte`, `struct`, `map`, `slice` and `io.Reader` too + * Auto detects `Content-Type` + * Buffer less processing for `io.Reader` + * Native `*http.Request` instance may be accessed during middleware and request execution via `Request.RawRequest` + * Request Body can be read multiple times via `Request.RawRequest.GetBody()` + * [Response](https://pkg.go.dev/github.com/go-resty/resty/v2#Response) object gives you more possibility + * Access as `[]byte` array - `response.Body()` OR Access as `string` - `response.String()` + * Know your `response.Time()` and when we `response.ReceivedAt()` + * Automatic marshal and unmarshal for `JSON` and `XML` content type + * Default is `JSON`, if you supply `struct/map` without header `Content-Type` + * For auto-unmarshal, refer to - + - Success scenario [Request.SetResult()](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetResult) and [Response.Result()](https://pkg.go.dev/github.com/go-resty/resty/v2#Response.Result). + - Error scenario [Request.SetError()](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetError) and [Response.Error()](https://pkg.go.dev/github.com/go-resty/resty/v2#Response.Error). + - Supports [RFC7807](https://tools.ietf.org/html/rfc7807) - `application/problem+json` & `application/problem+xml` + * Resty provides an option to override [JSON Marshal/Unmarshal and XML Marshal/Unmarshal](#override-json--xml-marshalunmarshal) + * Easy to upload one or more file(s) via `multipart/form-data` + * Auto detects file content type + * Request URL [Path Params (aka URI Params)](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetPathParams) + * Backoff Retry Mechanism with retry condition function [reference](retry_test.go) + * Resty client HTTP & REST [Request](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.OnBeforeRequest) and [Response](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.OnAfterResponse) middlewares + * `Request.SetContext` supported + * Authorization option of `BasicAuth` and `Bearer` token + * Set request `ContentLength` value for all request or particular request + * Custom [Root Certificates](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetRootCertificate) and Client [Certificates](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetCertificates) + * Download/Save HTTP response directly into File, like `curl -o` flag. See [SetOutputDirectory](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetOutputDirectory) & [SetOutput](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetOutput). + * Cookies for your request and CookieJar support + * SRV Record based request instead of Host URL + * Client settings like `Timeout`, `RedirectPolicy`, `Proxy`, `TLSClientConfig`, `Transport`, etc. + * Optionally allows GET request with payload, see [SetAllowGetMethodPayload](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetAllowGetMethodPayload) + * Supports registering external JSON library into resty, see [how to use](https://github.com/go-resty/resty/issues/76#issuecomment-314015250) + * Exposes Response reader without reading response (no auto-unmarshaling) if need be, see [how to use](https://github.com/go-resty/resty/issues/87#issuecomment-322100604) + * Option to specify expected `Content-Type` when response `Content-Type` header missing. Refer to [#92](https://github.com/go-resty/resty/issues/92) + * Resty design + * Have client level settings & options and also override at Request level if you want to + * Request and Response middleware + * Create Multiple clients if you want to `resty.New()` + * Supports `http.RoundTripper` implementation, see [SetTransport](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetTransport) + * goroutine concurrent safe + * Resty Client trace, see [Client.EnableTrace](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.EnableTrace) and [Request.EnableTrace](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.EnableTrace) + * Since v2.4.0, trace info contains a `RequestAttempt` value, and the `Request` object contains an `Attempt` attribute + * Debug mode - clean and informative logging presentation + * Gzip - Go does it automatically also resty has fallback handling too + * Works fine with `HTTP/2` and `HTTP/1.1` + * [Bazel support](#bazel-support) + * Easily mock Resty for testing, [for e.g.](#mocking-http-requests-using-httpmock-library) + * Well tested client library + +### Included Batteries + + * Redirect Policies - see [how to use](#redirect-policy) + * NoRedirectPolicy + * FlexibleRedirectPolicy + * DomainCheckRedirectPolicy + * etc. [more info](redirect.go) + * Retry Mechanism [how to use](#retries) + * Backoff Retry + * Conditional Retry + * Since v2.6.0, Retry Hooks - [Client](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.AddRetryHook), [Request](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.AddRetryHook) + * SRV Record based request instead of Host URL [how to use](resty_test.go#L1412) + * etc (upcoming - throw your idea's [here](https://github.com/go-resty/resty/issues)). + + +#### Supported Go Versions + +Initially Resty started supporting `go modules` since `v1.10.0` release. + +Starting Resty v2 and higher versions, it fully embraces [go modules](https://github.com/golang/go/wiki/Modules) package release. It requires a Go version capable of understanding `/vN` suffixed imports: + +- 1.9.7+ +- 1.10.3+ +- 1.11+ + + +## It might be beneficial for your project :smile: + +Resty author also published following projects for Go Community. + + * [aah framework](https://aahframework.org) - A secure, flexible, rapid Go web framework. + * [THUMBAI](https://thumbai.app) - Go Mod Repository, Go Vanity Service and Simple Proxy Server. + * [go-model](https://github.com/jeevatkm/go-model) - Robust & Easy to use model mapper and utility methods for Go `struct`. + + +## Installation + +```bash +# Go Modules +require github.com/go-resty/resty/v2 v2.7.0 +``` + +## Usage + +The following samples will assist you to become as comfortable as possible with resty library. + +```go +// Import resty into your code and refer it as `resty`. +import "github.com/go-resty/resty/v2" +``` + +#### Simple GET + +```go +// Create a Resty Client +client := resty.New() + +resp, err := client.R(). + EnableTrace(). + Get("https://httpbin.org/get") + +// Explore response object +fmt.Println("Response Info:") +fmt.Println(" Error :", err) +fmt.Println(" Status Code:", resp.StatusCode()) +fmt.Println(" Status :", resp.Status()) +fmt.Println(" Proto :", resp.Proto()) +fmt.Println(" Time :", resp.Time()) +fmt.Println(" Received At:", resp.ReceivedAt()) +fmt.Println(" Body :\n", resp) +fmt.Println() + +// Explore trace info +fmt.Println("Request Trace Info:") +ti := resp.Request.TraceInfo() +fmt.Println(" DNSLookup :", ti.DNSLookup) +fmt.Println(" ConnTime :", ti.ConnTime) +fmt.Println(" TCPConnTime :", ti.TCPConnTime) +fmt.Println(" TLSHandshake :", ti.TLSHandshake) +fmt.Println(" ServerTime :", ti.ServerTime) +fmt.Println(" ResponseTime :", ti.ResponseTime) +fmt.Println(" TotalTime :", ti.TotalTime) +fmt.Println(" IsConnReused :", ti.IsConnReused) +fmt.Println(" IsConnWasIdle :", ti.IsConnWasIdle) +fmt.Println(" ConnIdleTime :", ti.ConnIdleTime) +fmt.Println(" RequestAttempt:", ti.RequestAttempt) +fmt.Println(" RemoteAddr :", ti.RemoteAddr.String()) + +/* Output +Response Info: + Error : + Status Code: 200 + Status : 200 OK + Proto : HTTP/2.0 + Time : 457.034718ms + Received At: 2020-09-14 15:35:29.784681 -0700 PDT m=+0.458137045 + Body : + { + "args": {}, + "headers": { + "Accept-Encoding": "gzip", + "Host": "httpbin.org", + "User-Agent": "go-resty/2.4.0 (https://github.com/go-resty/resty)", + "X-Amzn-Trace-Id": "Root=1-5f5ff031-000ff6292204aa6898e4de49" + }, + "origin": "0.0.0.0", + "url": "https://httpbin.org/get" + } + +Request Trace Info: + DNSLookup : 4.074657ms + ConnTime : 381.709936ms + TCPConnTime : 77.428048ms + TLSHandshake : 299.623597ms + ServerTime : 75.414703ms + ResponseTime : 79.337µs + TotalTime : 457.034718ms + IsConnReused : false + IsConnWasIdle : false + ConnIdleTime : 0s + RequestAttempt: 1 + RemoteAddr : 3.221.81.55:443 +*/ +``` + +#### Enhanced GET + +```go +// Create a Resty Client +client := resty.New() + +resp, err := client.R(). + SetQueryParams(map[string]string{ + "page_no": "1", + "limit": "20", + "sort":"name", + "order": "asc", + "random":strconv.FormatInt(time.Now().Unix(), 10), + }). + SetHeader("Accept", "application/json"). + SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F"). + Get("/search_result") + + +// Sample of using Request.SetQueryString method +resp, err := client.R(). + SetQueryString("productId=232&template=fresh-sample&cat=resty&source=google&kw=buy a lot more"). + SetHeader("Accept", "application/json"). + SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F"). + Get("/show_product") + + +// If necessary, you can force response content type to tell Resty to parse a JSON response into your struct +resp, err := client.R(). + SetResult(result). + ForceContentType("application/json"). + Get("v2/alpine/manifests/latest") +``` + +#### Various POST method combinations + +```go +// Create a Resty Client +client := resty.New() + +// POST JSON string +// No need to set content type, if you have client level setting +resp, err := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody(`{"username":"testuser", "password":"testpass"}`). + SetResult(&AuthSuccess{}). // or SetResult(AuthSuccess{}). + Post("https://myapp.com/login") + +// POST []byte array +// No need to set content type, if you have client level setting +resp, err := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody([]byte(`{"username":"testuser", "password":"testpass"}`)). + SetResult(&AuthSuccess{}). // or SetResult(AuthSuccess{}). + Post("https://myapp.com/login") + +// POST Struct, default is JSON content type. No need to set one +resp, err := client.R(). + SetBody(User{Username: "testuser", Password: "testpass"}). + SetResult(&AuthSuccess{}). // or SetResult(AuthSuccess{}). + SetError(&AuthError{}). // or SetError(AuthError{}). + Post("https://myapp.com/login") + +// POST Map, default is JSON content type. No need to set one +resp, err := client.R(). + SetBody(map[string]interface{}{"username": "testuser", "password": "testpass"}). + SetResult(&AuthSuccess{}). // or SetResult(AuthSuccess{}). + SetError(&AuthError{}). // or SetError(AuthError{}). + Post("https://myapp.com/login") + +// POST of raw bytes for file upload. For example: upload file to Dropbox +fileBytes, _ := ioutil.ReadFile("/Users/jeeva/mydocument.pdf") + +// See we are not setting content-type header, since go-resty automatically detects Content-Type for you +resp, err := client.R(). + SetBody(fileBytes). + SetContentLength(true). // Dropbox expects this value + SetAuthToken(""). + SetError(&DropboxError{}). // or SetError(DropboxError{}). + Post("https://content.dropboxapi.com/1/files_put/auto/resty/mydocument.pdf") // for upload Dropbox supports PUT too + +// Note: resty detects Content-Type for request body/payload if content type header is not set. +// * For struct and map data type defaults to 'application/json' +// * Fallback is plain text content type +``` + +#### Sample PUT + +You can use various combinations of `PUT` method call like demonstrated for `POST`. + +```go +// Note: This is one sample of PUT method usage, refer POST for more combination + +// Create a Resty Client +client := resty.New() + +// Request goes as JSON content type +// No need to set auth token, error, if you have client level settings +resp, err := client.R(). + SetBody(Article{ + Title: "go-resty", + Content: "This is my article content, oh ya!", + Author: "Jeevanandam M", + Tags: []string{"article", "sample", "resty"}, + }). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + SetError(&Error{}). // or SetError(Error{}). + Put("https://myapp.com/article/1234") +``` + +#### Sample PATCH + +You can use various combinations of `PATCH` method call like demonstrated for `POST`. + +```go +// Note: This is one sample of PUT method usage, refer POST for more combination + +// Create a Resty Client +client := resty.New() + +// Request goes as JSON content type +// No need to set auth token, error, if you have client level settings +resp, err := client.R(). + SetBody(Article{ + Tags: []string{"new tag1", "new tag2"}, + }). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + SetError(&Error{}). // or SetError(Error{}). + Patch("https://myapp.com/articles/1234") +``` + +#### Sample DELETE, HEAD, OPTIONS + +```go +// Create a Resty Client +client := resty.New() + +// DELETE a article +// No need to set auth token, error, if you have client level settings +resp, err := client.R(). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + SetError(&Error{}). // or SetError(Error{}). + Delete("https://myapp.com/articles/1234") + +// DELETE a articles with payload/body as a JSON string +// No need to set auth token, error, if you have client level settings +resp, err := client.R(). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + SetError(&Error{}). // or SetError(Error{}). + SetHeader("Content-Type", "application/json"). + SetBody(`{article_ids: [1002, 1006, 1007, 87683, 45432] }`). + Delete("https://myapp.com/articles") + +// HEAD of resource +// No need to set auth token, if you have client level settings +resp, err := client.R(). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + Head("https://myapp.com/videos/hi-res-video") + +// OPTIONS of resource +// No need to set auth token, if you have client level settings +resp, err := client.R(). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + Options("https://myapp.com/servers/nyc-dc-01") +``` + +#### Override JSON & XML Marshal/Unmarshal + +User could register choice of JSON/XML library into resty or write your own. By default resty registers standard `encoding/json` and `encoding/xml` respectively. +```go +// Example of registering json-iterator +import jsoniter "github.com/json-iterator/go" + +json := jsoniter.ConfigCompatibleWithStandardLibrary + +client := resty.New() +client.JSONMarshal = json.Marshal +client.JSONUnmarshal = json.Unmarshal + +// similarly user could do for XML too with - +client.XMLMarshal +client.XMLUnmarshal +``` + +### Multipart File(s) upload + +#### Using io.Reader + +```go +profileImgBytes, _ := ioutil.ReadFile("/Users/jeeva/test-img.png") +notesBytes, _ := ioutil.ReadFile("/Users/jeeva/text-file.txt") + +// Create a Resty Client +client := resty.New() + +resp, err := client.R(). + SetFileReader("profile_img", "test-img.png", bytes.NewReader(profileImgBytes)). + SetFileReader("notes", "text-file.txt", bytes.NewReader(notesBytes)). + SetFormData(map[string]string{ + "first_name": "Jeevanandam", + "last_name": "M", + }). + Post("http://myapp.com/upload") +``` + +#### Using File directly from Path + +```go +// Create a Resty Client +client := resty.New() + +// Single file scenario +resp, err := client.R(). + SetFile("profile_img", "/Users/jeeva/test-img.png"). + Post("http://myapp.com/upload") + +// Multiple files scenario +resp, err := client.R(). + SetFiles(map[string]string{ + "profile_img": "/Users/jeeva/test-img.png", + "notes": "/Users/jeeva/text-file.txt", + }). + Post("http://myapp.com/upload") + +// Multipart of form fields and files +resp, err := client.R(). + SetFiles(map[string]string{ + "profile_img": "/Users/jeeva/test-img.png", + "notes": "/Users/jeeva/text-file.txt", + }). + SetFormData(map[string]string{ + "first_name": "Jeevanandam", + "last_name": "M", + "zip_code": "00001", + "city": "my city", + "access_token": "C6A79608-782F-4ED0-A11D-BD82FAD829CD", + }). + Post("http://myapp.com/profile") +``` + +#### Sample Form submission + +```go +// Create a Resty Client +client := resty.New() + +// just mentioning about POST as an example with simple flow +// User Login +resp, err := client.R(). + SetFormData(map[string]string{ + "username": "jeeva", + "password": "mypass", + }). + Post("http://myapp.com/login") + +// Followed by profile update +resp, err := client.R(). + SetFormData(map[string]string{ + "first_name": "Jeevanandam", + "last_name": "M", + "zip_code": "00001", + "city": "new city update", + }). + Post("http://myapp.com/profile") + +// Multi value form data +criteria := url.Values{ + "search_criteria": []string{"book", "glass", "pencil"}, +} +resp, err := client.R(). + SetFormDataFromValues(criteria). + Post("http://myapp.com/search") +``` + +#### Save HTTP Response into File + +```go +// Create a Resty Client +client := resty.New() + +// Setting output directory path, If directory not exists then resty creates one! +// This is optional one, if you're planning using absoule path in +// `Request.SetOutput` and can used together. +client.SetOutputDirectory("/Users/jeeva/Downloads") + +// HTTP response gets saved into file, similar to curl -o flag +_, err := client.R(). + SetOutput("plugin/ReplyWithHeader-v5.1-beta.zip"). + Get("http://bit.ly/1LouEKr") + +// OR using absolute path +// Note: output directory path is not used for absolute path +_, err := client.R(). + SetOutput("/MyDownloads/plugin/ReplyWithHeader-v5.1-beta.zip"). + Get("http://bit.ly/1LouEKr") +``` + +#### Request URL Path Params + +Resty provides easy to use dynamic request URL path params. Params can be set at client and request level. Client level params value can be overridden at request level. + +```go +// Create a Resty Client +client := resty.New() + +client.R().SetPathParams(map[string]string{ + "userId": "sample@sample.com", + "subAccountId": "100002", +}). +Get("/v1/users/{userId}/{subAccountId}/details") + +// Result: +// Composed URL - /v1/users/sample@sample.com/100002/details +``` + +#### Request and Response Middleware + +Resty provides middleware ability to manipulate for Request and Response. It is more flexible than callback approach. + +```go +// Create a Resty Client +client := resty.New() + +// Registering Request Middleware +client.OnBeforeRequest(func(c *resty.Client, req *resty.Request) error { + // Now you have access to Client and current Request object + // manipulate it as per your need + + return nil // if its success otherwise return error + }) + +// Registering Response Middleware +client.OnAfterResponse(func(c *resty.Client, resp *resty.Response) error { + // Now you have access to Client and current Response object + // manipulate it as per your need + + return nil // if its success otherwise return error + }) +``` + +#### OnError Hooks + +Resty provides OnError hooks that may be called because: + +- The client failed to send the request due to connection timeout, TLS handshake failure, etc... +- The request was retried the maximum amount of times, and still failed. + +If there was a response from the server, the original error will be wrapped in `*resty.ResponseError` which contains the last response received. + +```go +// Create a Resty Client +client := resty.New() + +client.OnError(func(req *resty.Request, err error) { + if v, ok := err.(*resty.ResponseError); ok { + // v.Response contains the last response from the server + // v.Err contains the original error + } + // Log the error, increment a metric, etc... +}) +``` + +#### Redirect Policy + +Resty provides few ready to use redirect policy(s) also it supports multiple policies together. + +```go +// Create a Resty Client +client := resty.New() + +// Assign Client Redirect Policy. Create one as per you need +client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(15)) + +// Wanna multiple policies such as redirect count, domain name check, etc +client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20), + resty.DomainCheckRedirectPolicy("host1.com", "host2.org", "host3.net")) +``` + +##### Custom Redirect Policy + +Implement [RedirectPolicy](redirect.go#L20) interface and register it with resty client. Have a look [redirect.go](redirect.go) for more information. + +```go +// Create a Resty Client +client := resty.New() + +// Using raw func into resty.SetRedirectPolicy +client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { + // Implement your logic here + + // return nil for continue redirect otherwise return error to stop/prevent redirect + return nil +})) + +//--------------------------------------------------- + +// Using struct create more flexible redirect policy +type CustomRedirectPolicy struct { + // variables goes here +} + +func (c *CustomRedirectPolicy) Apply(req *http.Request, via []*http.Request) error { + // Implement your logic here + + // return nil for continue redirect otherwise return error to stop/prevent redirect + return nil +} + +// Registering in resty +client.SetRedirectPolicy(CustomRedirectPolicy{/* initialize variables */}) +``` + +#### Custom Root Certificates and Client Certificates + +```go +// Create a Resty Client +client := resty.New() + +// Custom Root certificates, just supply .pem file. +// you can add one or more root certificates, its get appended +client.SetRootCertificate("/path/to/root/pemFile1.pem") +client.SetRootCertificate("/path/to/root/pemFile2.pem") +// ... and so on! + +// Adding Client Certificates, you add one or more certificates +// Sample for creating certificate object +// Parsing public/private key pair from a pair of files. The files must contain PEM encoded data. +cert1, err := tls.LoadX509KeyPair("certs/client.pem", "certs/client.key") +if err != nil { + log.Fatalf("ERROR client certificate: %s", err) +} +// ... + +// You add one or more certificates +client.SetCertificates(cert1, cert2, cert3) +``` + +#### Custom Root Certificates and Client Certificates from string + +```go +// Custom Root certificates from string +// You can pass you certificates throught env variables as strings +// you can add one or more root certificates, its get appended +client.SetRootCertificateFromString("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----") +client.SetRootCertificateFromString("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----") +// ... and so on! + +// Adding Client Certificates, you add one or more certificates +// Sample for creating certificate object +// Parsing public/private key pair from a pair of files. The files must contain PEM encoded data. +cert1, err := tls.X509KeyPair([]byte("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----"), []byte("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----")) +if err != nil { + log.Fatalf("ERROR client certificate: %s", err) +} +// ... + +// You add one or more certificates +client.SetCertificates(cert1, cert2, cert3) +``` + +#### Proxy Settings - Client as well as at Request Level + +Default `Go` supports Proxy via environment variable `HTTP_PROXY`. Resty provides support via `SetProxy` & `RemoveProxy`. +Choose as per your need. + +**Client Level Proxy** settings applied to all the request + +```go +// Create a Resty Client +client := resty.New() + +// Setting a Proxy URL and Port +client.SetProxy("http://proxyserver:8888") + +// Want to remove proxy setting +client.RemoveProxy() +``` + +#### Retries + +Resty uses [backoff](http://www.awsarchitectureblog.com/2015/03/backoff.html) +to increase retry intervals after each attempt. + +Usage example: + +```go +// Create a Resty Client +client := resty.New() + +// Retries are configured per client +client. + // Set retry count to non zero to enable retries + SetRetryCount(3). + // You can override initial retry wait time. + // Default is 100 milliseconds. + SetRetryWaitTime(5 * time.Second). + // MaxWaitTime can be overridden as well. + // Default is 2 seconds. + SetRetryMaxWaitTime(20 * time.Second). + // SetRetryAfter sets callback to calculate wait time between retries. + // Default (nil) implies exponential backoff with jitter + SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) { + return 0, errors.New("quota exceeded") + }) +``` + +Above setup will result in resty retrying requests returned non nil error up to +3 times with delay increased after each attempt. + +You can optionally provide client with [custom retry conditions](https://pkg.go.dev/github.com/go-resty/resty/v2#RetryConditionFunc): + +```go +// Create a Resty Client +client := resty.New() + +client.AddRetryCondition( + // RetryConditionFunc type is for retry condition function + // input: non-nil Response OR request execution error + func(r *resty.Response, err error) bool { + return r.StatusCode() == http.StatusTooManyRequests + }, +) +``` + +Above example will make resty retry requests ended with `429 Too Many Requests` +status code. + +Multiple retry conditions can be added. + +It is also possible to use `resty.Backoff(...)` to get arbitrary retry scenarios +implemented. [Reference](retry_test.go). + +#### Allow GET request with Payload + +```go +// Create a Resty Client +client := resty.New() + +// Allow GET request with Payload. This is disabled by default. +client.SetAllowGetMethodPayload(true) +``` + +#### Wanna Multiple Clients + +```go +// Here you go! +// Client 1 +client1 := resty.New() +client1.R().Get("http://httpbin.org") +// ... + +// Client 2 +client2 := resty.New() +client2.R().Head("http://httpbin.org") +// ... + +// Bend it as per your need!!! +``` + +#### Remaining Client Settings & its Options + +```go +// Create a Resty Client +client := resty.New() + +// Unique settings at Client level +//-------------------------------- +// Enable debug mode +client.SetDebug(true) + +// Assign Client TLSClientConfig +// One can set custom root-certificate. Refer: http://golang.org/pkg/crypto/tls/#example_Dial +client.SetTLSClientConfig(&tls.Config{ RootCAs: roots }) + +// or One can disable security check (https) +client.SetTLSClientConfig(&tls.Config{ InsecureSkipVerify: true }) + +// Set client timeout as per your need +client.SetTimeout(1 * time.Minute) + + +// You can override all below settings and options at request level if you want to +//-------------------------------------------------------------------------------- +// Host URL for all request. So you can use relative URL in the request +client.SetHostURL("http://httpbin.org") + +// Headers for all request +client.SetHeader("Accept", "application/json") +client.SetHeaders(map[string]string{ + "Content-Type": "application/json", + "User-Agent": "My custom User Agent String", + }) + +// Cookies for all request +client.SetCookie(&http.Cookie{ + Name:"go-resty", + Value:"This is cookie value", + Path: "/", + Domain: "sample.com", + MaxAge: 36000, + HttpOnly: true, + Secure: false, + }) +client.SetCookies(cookies) + +// URL query parameters for all request +client.SetQueryParam("user_id", "00001") +client.SetQueryParams(map[string]string{ // sample of those who use this manner + "api_key": "api-key-here", + "api_secert": "api-secert", + }) +client.R().SetQueryString("productId=232&template=fresh-sample&cat=resty&source=google&kw=buy a lot more") + +// Form data for all request. Typically used with POST and PUT +client.SetFormData(map[string]string{ + "access_token": "BC594900-518B-4F7E-AC75-BD37F019E08F", + }) + +// Basic Auth for all request +client.SetBasicAuth("myuser", "mypass") + +// Bearer Auth Token for all request +client.SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F") + +// Enabling Content length value for all request +client.SetContentLength(true) + +// Registering global Error object structure for JSON/XML request +client.SetError(&Error{}) // or resty.SetError(Error{}) +``` + +#### Unix Socket + +```go +unixSocket := "/var/run/my_socket.sock" + +// Create a Go's http.Transport so we can set it in resty. +transport := http.Transport{ + Dial: func(_, _ string) (net.Conn, error) { + return net.Dial("unix", unixSocket) + }, +} + +// Create a Resty Client +client := resty.New() + +// Set the previous transport that we created, set the scheme of the communication to the +// socket and set the unixSocket as the HostURL. +client.SetTransport(&transport).SetScheme("http").SetHostURL(unixSocket) + +// No need to write the host's URL on the request, just the path. +client.R().Get("/index.html") +``` + +#### Bazel Support + +Resty can be built, tested and depended upon via [Bazel](https://bazel.build). +For example, to run all tests: + +```shell +bazel test :resty_test +``` + +#### Mocking http requests using [httpmock](https://github.com/jarcoal/httpmock) library + +In order to mock the http requests when testing your application you +could use the `httpmock` library. + +When using the default resty client, you should pass the client to the library as follow: + +```go +// Create a Resty Client +client := resty.New() + +// Get the underlying HTTP Client and set it to Mock +httpmock.ActivateNonDefault(client.GetClient()) +``` + +More detailed example of mocking resty http requests using ginko could be found [here](https://github.com/jarcoal/httpmock#ginkgo--resty-example). + +## Versioning + +Resty releases versions according to [Semantic Versioning](http://semver.org) + + * Resty v2 does not use `gopkg.in` service for library versioning. + * Resty fully adapted to `go mod` capabilities since `v1.10.0` release. + * Resty v1 series was using `gopkg.in` to provide versioning. `gopkg.in/resty.vX` points to appropriate tagged versions; `X` denotes version series number and it's a stable release for production use. For e.g. `gopkg.in/resty.v0`. + * Development takes place at the master branch. Although the code in master should always compile and test successfully, it might break API's. I aim to maintain backwards compatibility, but sometimes API's and behavior might be changed to fix a bug. + +## Contribution + +I would welcome your contribution! If you find any improvement or issue you want to fix, feel free to send a pull request, I like pull requests that include test cases for fix/enhancement. I have done my best to bring pretty good code coverage. Feel free to write tests. + +BTW, I'd like to know what you think about `Resty`. Kindly open an issue or send me an email; it'd mean a lot to me. + +## Creator + +[Jeevanandam M.](https://github.com/jeevatkm) (jeeva@myjeeva.com) + +## Core Team + +Have a look on [Members](https://github.com/orgs/go-resty/people) page. + +## Contributors + +Have a look on [Contributors](https://github.com/go-resty/resty/graphs/contributors) page. + +## License + +Resty released under MIT license, refer [LICENSE](LICENSE) file. diff --git a/vendor/github.com/go-resty/resty/v2/WORKSPACE b/vendor/github.com/go-resty/resty/v2/WORKSPACE new file mode 100644 index 000000000..9ef03e95a --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/WORKSPACE @@ -0,0 +1,31 @@ +workspace(name = "resty") + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "io_bazel_rules_go", + sha256 = "69de5c704a05ff37862f7e0f5534d4f479418afc21806c887db544a316f3cb6b", + urls = [ + "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.27.0/rules_go-v0.27.0.tar.gz", + "https://github.com/bazelbuild/rules_go/releases/download/v0.27.0/rules_go-v0.27.0.tar.gz", + ], +) + +http_archive( + name = "bazel_gazelle", + sha256 = "62ca106be173579c0a167deb23358fdfe71ffa1e4cfdddf5582af26520f1c66f", + urls = [ + "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.23.0/bazel-gazelle-v0.23.0.tar.gz", + "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.23.0/bazel-gazelle-v0.23.0.tar.gz", + ], +) + +load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies") + +go_rules_dependencies() + +go_register_toolchains(version = "1.16") + +load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies") + +gazelle_dependencies() diff --git a/vendor/github.com/go-resty/resty/v2/client.go b/vendor/github.com/go-resty/resty/v2/client.go new file mode 100644 index 000000000..1a03efa37 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/client.go @@ -0,0 +1,1115 @@ +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "bytes" + "compress/gzip" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "io/ioutil" + "math" + "net/http" + "net/url" + "reflect" + "regexp" + "strings" + "sync" + "time" +) + +const ( + // MethodGet HTTP method + MethodGet = "GET" + + // MethodPost HTTP method + MethodPost = "POST" + + // MethodPut HTTP method + MethodPut = "PUT" + + // MethodDelete HTTP method + MethodDelete = "DELETE" + + // MethodPatch HTTP method + MethodPatch = "PATCH" + + // MethodHead HTTP method + MethodHead = "HEAD" + + // MethodOptions HTTP method + MethodOptions = "OPTIONS" +) + +var ( + hdrUserAgentKey = http.CanonicalHeaderKey("User-Agent") + hdrAcceptKey = http.CanonicalHeaderKey("Accept") + hdrContentTypeKey = http.CanonicalHeaderKey("Content-Type") + hdrContentLengthKey = http.CanonicalHeaderKey("Content-Length") + hdrContentEncodingKey = http.CanonicalHeaderKey("Content-Encoding") + hdrLocationKey = http.CanonicalHeaderKey("Location") + + plainTextType = "text/plain; charset=utf-8" + jsonContentType = "application/json" + formContentType = "application/x-www-form-urlencoded" + + jsonCheck = regexp.MustCompile(`(?i:(application|text)/(json|.*\+json|json\-.*)(;|$))`) + xmlCheck = regexp.MustCompile(`(?i:(application|text)/(xml|.*\+xml)(;|$))`) + + hdrUserAgentValue = "go-resty/" + Version + " (https://github.com/go-resty/resty)" + bufPool = &sync.Pool{New: func() interface{} { return &bytes.Buffer{} }} +) + +type ( + // RequestMiddleware type is for request middleware, called before a request is sent + RequestMiddleware func(*Client, *Request) error + + // ResponseMiddleware type is for response middleware, called after a response has been received + ResponseMiddleware func(*Client, *Response) error + + // PreRequestHook type is for the request hook, called right before the request is sent + PreRequestHook func(*Client, *http.Request) error + + // RequestLogCallback type is for request logs, called before the request is logged + RequestLogCallback func(*RequestLog) error + + // ResponseLogCallback type is for response logs, called before the response is logged + ResponseLogCallback func(*ResponseLog) error + + // ErrorHook type is for reacting to request errors, called after all retries were attempted + ErrorHook func(*Request, error) +) + +// Client struct is used to create Resty client with client level settings, +// these settings are applicable to all the request raised from the client. +// +// Resty also provides an options to override most of the client settings +// at request level. +type Client struct { + BaseURL string + HostURL string // Deprecated: use BaseURL instead. To be removed in v3.0.0 release. + QueryParam url.Values + FormData url.Values + PathParams map[string]string + Header http.Header + UserInfo *User + Token string + AuthScheme string + Cookies []*http.Cookie + Error reflect.Type + Debug bool + DisableWarn bool + AllowGetMethodPayload bool + RetryCount int + RetryWaitTime time.Duration + RetryMaxWaitTime time.Duration + RetryConditions []RetryConditionFunc + RetryHooks []OnRetryFunc + RetryAfter RetryAfterFunc + JSONMarshal func(v interface{}) ([]byte, error) + JSONUnmarshal func(data []byte, v interface{}) error + XMLMarshal func(v interface{}) ([]byte, error) + XMLUnmarshal func(data []byte, v interface{}) error + + // HeaderAuthorizationKey is used to set/access Request Authorization header + // value when `SetAuthToken` option is used. + HeaderAuthorizationKey string + + jsonEscapeHTML bool + setContentLength bool + closeConnection bool + notParseResponse bool + trace bool + debugBodySizeLimit int64 + outputDirectory string + scheme string + log Logger + httpClient *http.Client + proxyURL *url.URL + beforeRequest []RequestMiddleware + udBeforeRequest []RequestMiddleware + preReqHook PreRequestHook + afterResponse []ResponseMiddleware + requestLog RequestLogCallback + responseLog ResponseLogCallback + errorHooks []ErrorHook +} + +// User type is to hold an username and password information +type User struct { + Username, Password string +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Client methods +//___________________________________ + +// SetHostURL method is to set Host URL in the client instance. It will be used with request +// raised from this client with relative URL +// // Setting HTTP address +// client.SetHostURL("http://myjeeva.com") +// +// // Setting HTTPS address +// client.SetHostURL("https://myjeeva.com") +// +// Deprecated: use SetBaseURL instead. To be removed in v3.0.0 release. +func (c *Client) SetHostURL(url string) *Client { + c.SetBaseURL(url) + return c +} + +// SetBaseURL method is to set Base URL in the client instance. It will be used with request +// raised from this client with relative URL +// // Setting HTTP address +// client.SetBaseURL("http://myjeeva.com") +// +// // Setting HTTPS address +// client.SetBaseURL("https://myjeeva.com") +// +// Since v2.7.0 +func (c *Client) SetBaseURL(url string) *Client { + c.BaseURL = strings.TrimRight(url, "/") + c.HostURL = c.BaseURL + return c +} + +// SetHeader method sets a single header field and its value in the client instance. +// These headers will be applied to all requests raised from this client instance. +// Also it can be overridden at request level header options. +// +// See `Request.SetHeader` or `Request.SetHeaders`. +// +// For Example: To set `Content-Type` and `Accept` as `application/json` +// +// client. +// SetHeader("Content-Type", "application/json"). +// SetHeader("Accept", "application/json") +func (c *Client) SetHeader(header, value string) *Client { + c.Header.Set(header, value) + return c +} + +// SetHeaders method sets multiple headers field and its values at one go in the client instance. +// These headers will be applied to all requests raised from this client instance. Also it can be +// overridden at request level headers options. +// +// See `Request.SetHeaders` or `Request.SetHeader`. +// +// For Example: To set `Content-Type` and `Accept` as `application/json` +// +// client.SetHeaders(map[string]string{ +// "Content-Type": "application/json", +// "Accept": "application/json", +// }) +func (c *Client) SetHeaders(headers map[string]string) *Client { + for h, v := range headers { + c.Header.Set(h, v) + } + return c +} + +// SetHeaderVerbatim method is to set a single header field and its value verbatim in the current request. +// +// For Example: To set `all_lowercase` and `UPPERCASE` as `available`. +// client.R(). +// SetHeaderVerbatim("all_lowercase", "available"). +// SetHeaderVerbatim("UPPERCASE", "available") +// +// Also you can override header value, which was set at client instance level. +// +// Since v2.6.0 +func (c *Client) SetHeaderVerbatim(header, value string) *Client { + c.Header[header] = []string{value} + return c +} + +// SetCookieJar method sets custom http.CookieJar in the resty client. Its way to override default. +// +// For Example: sometimes we don't want to save cookies in api contacting, we can remove the default +// CookieJar in resty client. +// +// client.SetCookieJar(nil) +func (c *Client) SetCookieJar(jar http.CookieJar) *Client { + c.httpClient.Jar = jar + return c +} + +// SetCookie method appends a single cookie in the client instance. +// These cookies will be added to all the request raised from this client instance. +// client.SetCookie(&http.Cookie{ +// Name:"go-resty", +// Value:"This is cookie value", +// }) +func (c *Client) SetCookie(hc *http.Cookie) *Client { + c.Cookies = append(c.Cookies, hc) + return c +} + +// SetCookies method sets an array of cookies in the client instance. +// These cookies will be added to all the request raised from this client instance. +// cookies := []*http.Cookie{ +// &http.Cookie{ +// Name:"go-resty-1", +// Value:"This is cookie 1 value", +// }, +// &http.Cookie{ +// Name:"go-resty-2", +// Value:"This is cookie 2 value", +// }, +// } +// +// // Setting a cookies into resty +// client.SetCookies(cookies) +func (c *Client) SetCookies(cs []*http.Cookie) *Client { + c.Cookies = append(c.Cookies, cs...) + return c +} + +// SetQueryParam method sets single parameter and its value in the client instance. +// It will be formed as query string for the request. +// +// For Example: `search=kitchen%20papers&size=large` +// in the URL after `?` mark. These query params will be added to all the request raised from +// this client instance. Also it can be overridden at request level Query Param options. +// +// See `Request.SetQueryParam` or `Request.SetQueryParams`. +// client. +// SetQueryParam("search", "kitchen papers"). +// SetQueryParam("size", "large") +func (c *Client) SetQueryParam(param, value string) *Client { + c.QueryParam.Set(param, value) + return c +} + +// SetQueryParams method sets multiple parameters and their values at one go in the client instance. +// It will be formed as query string for the request. +// +// For Example: `search=kitchen%20papers&size=large` +// in the URL after `?` mark. These query params will be added to all the request raised from this +// client instance. Also it can be overridden at request level Query Param options. +// +// See `Request.SetQueryParams` or `Request.SetQueryParam`. +// client.SetQueryParams(map[string]string{ +// "search": "kitchen papers", +// "size": "large", +// }) +func (c *Client) SetQueryParams(params map[string]string) *Client { + for p, v := range params { + c.SetQueryParam(p, v) + } + return c +} + +// SetFormData method sets Form parameters and their values in the client instance. +// It's applicable only HTTP method `POST` and `PUT` and requets content type would be set as +// `application/x-www-form-urlencoded`. These form data will be added to all the request raised from +// this client instance. Also it can be overridden at request level form data. +// +// See `Request.SetFormData`. +// client.SetFormData(map[string]string{ +// "access_token": "BC594900-518B-4F7E-AC75-BD37F019E08F", +// "user_id": "3455454545", +// }) +func (c *Client) SetFormData(data map[string]string) *Client { + for k, v := range data { + c.FormData.Set(k, v) + } + return c +} + +// SetBasicAuth method sets the basic authentication header in the HTTP request. For Example: +// Authorization: Basic +// +// For Example: To set the header for username "go-resty" and password "welcome" +// client.SetBasicAuth("go-resty", "welcome") +// +// This basic auth information gets added to all the request rasied from this client instance. +// Also it can be overridden or set one at the request level is supported. +// +// See `Request.SetBasicAuth`. +func (c *Client) SetBasicAuth(username, password string) *Client { + c.UserInfo = &User{Username: username, Password: password} + return c +} + +// SetAuthToken method sets the auth token of the `Authorization` header for all HTTP requests. +// The default auth scheme is `Bearer`, it can be customized with the method `SetAuthScheme`. For Example: +// Authorization: +// +// For Example: To set auth token BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F +// +// client.SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F") +// +// This auth token gets added to all the requests rasied from this client instance. +// Also it can be overridden or set one at the request level is supported. +// +// See `Request.SetAuthToken`. +func (c *Client) SetAuthToken(token string) *Client { + c.Token = token + return c +} + +// SetAuthScheme method sets the auth scheme type in the HTTP request. For Example: +// Authorization: +// +// For Example: To set the scheme to use OAuth +// +// client.SetAuthScheme("OAuth") +// +// This auth scheme gets added to all the requests rasied from this client instance. +// Also it can be overridden or set one at the request level is supported. +// +// Information about auth schemes can be found in RFC7235 which is linked to below +// along with the page containing the currently defined official authentication schemes: +// https://tools.ietf.org/html/rfc7235 +// https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes +// +// See `Request.SetAuthToken`. +func (c *Client) SetAuthScheme(scheme string) *Client { + c.AuthScheme = scheme + return c +} + +// R method creates a new request instance, its used for Get, Post, Put, Delete, Patch, Head, Options, etc. +func (c *Client) R() *Request { + r := &Request{ + QueryParam: url.Values{}, + FormData: url.Values{}, + Header: http.Header{}, + Cookies: make([]*http.Cookie, 0), + + client: c, + multipartFiles: []*File{}, + multipartFields: []*MultipartField{}, + PathParams: map[string]string{}, + jsonEscapeHTML: true, + } + return r +} + +// NewRequest is an alias for method `R()`. Creates a new request instance, its used for +// Get, Post, Put, Delete, Patch, Head, Options, etc. +func (c *Client) NewRequest() *Request { + return c.R() +} + +// OnBeforeRequest method appends request middleware into the before request chain. +// Its gets applied after default Resty request middlewares and before request +// been sent from Resty to host server. +// client.OnBeforeRequest(func(c *resty.Client, r *resty.Request) error { +// // Now you have access to Client and Request instance +// // manipulate it as per your need +// +// return nil // if its success otherwise return error +// }) +func (c *Client) OnBeforeRequest(m RequestMiddleware) *Client { + c.udBeforeRequest = append(c.udBeforeRequest, m) + return c +} + +// OnAfterResponse method appends response middleware into the after response chain. +// Once we receive response from host server, default Resty response middleware +// gets applied and then user assigened response middlewares applied. +// client.OnAfterResponse(func(c *resty.Client, r *resty.Response) error { +// // Now you have access to Client and Response instance +// // manipulate it as per your need +// +// return nil // if its success otherwise return error +// }) +func (c *Client) OnAfterResponse(m ResponseMiddleware) *Client { + c.afterResponse = append(c.afterResponse, m) + return c +} + +// OnError method adds a callback that will be run whenever a request execution fails. +// This is called after all retries have been attempted (if any). +// If there was a response from the server, the error will be wrapped in *ResponseError +// which has the last response received from the server. +// +// client.OnError(func(req *resty.Request, err error) { +// if v, ok := err.(*resty.ResponseError); ok { +// // Do something with v.Response +// } +// // Log the error, increment a metric, etc... +// }) +func (c *Client) OnError(h ErrorHook) *Client { + c.errorHooks = append(c.errorHooks, h) + return c +} + +// SetPreRequestHook method sets the given pre-request function into resty client. +// It is called right before the request is fired. +// +// Note: Only one pre-request hook can be registered. Use `client.OnBeforeRequest` for mutilple. +func (c *Client) SetPreRequestHook(h PreRequestHook) *Client { + if c.preReqHook != nil { + c.log.Warnf("Overwriting an existing pre-request hook: %s", functionName(h)) + } + c.preReqHook = h + return c +} + +// SetDebug method enables the debug mode on Resty client. Client logs details of every request and response. +// For `Request` it logs information such as HTTP verb, Relative URL path, Host, Headers, Body if it has one. +// For `Response` it logs information such as Status, Response Time, Headers, Body if it has one. +// client.SetDebug(true) +func (c *Client) SetDebug(d bool) *Client { + c.Debug = d + return c +} + +// SetDebugBodyLimit sets the maximum size for which the response and request body will be logged in debug mode. +// client.SetDebugBodyLimit(1000000) +func (c *Client) SetDebugBodyLimit(sl int64) *Client { + c.debugBodySizeLimit = sl + return c +} + +// OnRequestLog method used to set request log callback into Resty. Registered callback gets +// called before the resty actually logs the information. +func (c *Client) OnRequestLog(rl RequestLogCallback) *Client { + if c.requestLog != nil { + c.log.Warnf("Overwriting an existing on-request-log callback from=%s to=%s", + functionName(c.requestLog), functionName(rl)) + } + c.requestLog = rl + return c +} + +// OnResponseLog method used to set response log callback into Resty. Registered callback gets +// called before the resty actually logs the information. +func (c *Client) OnResponseLog(rl ResponseLogCallback) *Client { + if c.responseLog != nil { + c.log.Warnf("Overwriting an existing on-response-log callback from=%s to=%s", + functionName(c.responseLog), functionName(rl)) + } + c.responseLog = rl + return c +} + +// SetDisableWarn method disables the warning message on Resty client. +// +// For Example: Resty warns the user when BasicAuth used on non-TLS mode. +// client.SetDisableWarn(true) +func (c *Client) SetDisableWarn(d bool) *Client { + c.DisableWarn = d + return c +} + +// SetAllowGetMethodPayload method allows the GET method with payload on Resty client. +// +// For Example: Resty allows the user sends request with a payload on HTTP GET method. +// client.SetAllowGetMethodPayload(true) +func (c *Client) SetAllowGetMethodPayload(a bool) *Client { + c.AllowGetMethodPayload = a + return c +} + +// SetLogger method sets given writer for logging Resty request and response details. +// +// Compliant to interface `resty.Logger`. +func (c *Client) SetLogger(l Logger) *Client { + c.log = l + return c +} + +// SetContentLength method enables the HTTP header `Content-Length` value for every request. +// By default Resty won't set `Content-Length`. +// client.SetContentLength(true) +// +// Also you have an option to enable for particular request. See `Request.SetContentLength` +func (c *Client) SetContentLength(l bool) *Client { + c.setContentLength = l + return c +} + +// SetTimeout method sets timeout for request raised from client. +// client.SetTimeout(time.Duration(1 * time.Minute)) +func (c *Client) SetTimeout(timeout time.Duration) *Client { + c.httpClient.Timeout = timeout + return c +} + +// SetError method is to register the global or client common `Error` object into Resty. +// It is used for automatic unmarshalling if response status code is greater than 399 and +// content type either JSON or XML. Can be pointer or non-pointer. +// client.SetError(&Error{}) +// // OR +// client.SetError(Error{}) +func (c *Client) SetError(err interface{}) *Client { + c.Error = typeOf(err) + return c +} + +// SetRedirectPolicy method sets the client redirect poilicy. Resty provides ready to use +// redirect policies. Wanna create one for yourself refer to `redirect.go`. +// +// client.SetRedirectPolicy(FlexibleRedirectPolicy(20)) +// +// // Need multiple redirect policies together +// client.SetRedirectPolicy(FlexibleRedirectPolicy(20), DomainCheckRedirectPolicy("host1.com", "host2.net")) +func (c *Client) SetRedirectPolicy(policies ...interface{}) *Client { + for _, p := range policies { + if _, ok := p.(RedirectPolicy); !ok { + c.log.Errorf("%v does not implement resty.RedirectPolicy (missing Apply method)", + functionName(p)) + } + } + + c.httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + for _, p := range policies { + if err := p.(RedirectPolicy).Apply(req, via); err != nil { + return err + } + } + return nil // looks good, go ahead + } + + return c +} + +// SetRetryCount method enables retry on Resty client and allows you +// to set no. of retry count. Resty uses a Backoff mechanism. +func (c *Client) SetRetryCount(count int) *Client { + c.RetryCount = count + return c +} + +// SetRetryWaitTime method sets default wait time to sleep before retrying +// request. +// +// Default is 100 milliseconds. +func (c *Client) SetRetryWaitTime(waitTime time.Duration) *Client { + c.RetryWaitTime = waitTime + return c +} + +// SetRetryMaxWaitTime method sets max wait time to sleep before retrying +// request. +// +// Default is 2 seconds. +func (c *Client) SetRetryMaxWaitTime(maxWaitTime time.Duration) *Client { + c.RetryMaxWaitTime = maxWaitTime + return c +} + +// SetRetryAfter sets callback to calculate wait time between retries. +// Default (nil) implies exponential backoff with jitter +func (c *Client) SetRetryAfter(callback RetryAfterFunc) *Client { + c.RetryAfter = callback + return c +} + +// AddRetryCondition method adds a retry condition function to array of functions +// that are checked to determine if the request is retried. The request will +// retry if any of the functions return true and error is nil. +// +// Note: These retry conditions are applied on all Request made using this Client. +// For Request specific retry conditions check *Request.AddRetryCondition +func (c *Client) AddRetryCondition(condition RetryConditionFunc) *Client { + c.RetryConditions = append(c.RetryConditions, condition) + return c +} + +// AddRetryAfterErrorCondition adds the basic condition of retrying after encountering +// an error from the http response +// +// Since v2.6.0 +func (c *Client) AddRetryAfterErrorCondition() *Client { + c.AddRetryCondition(func(response *Response, err error) bool { + return response.IsError() + }) + return c +} + +// AddRetryHook adds a side-effecting retry hook to an array of hooks +// that will be executed on each retry. +// +// Since v2.6.0 +func (c *Client) AddRetryHook(hook OnRetryFunc) *Client { + c.RetryHooks = append(c.RetryHooks, hook) + return c +} + +// SetTLSClientConfig method sets TLSClientConfig for underling client Transport. +// +// For Example: +// // One can set custom root-certificate. Refer: http://golang.org/pkg/crypto/tls/#example_Dial +// client.SetTLSClientConfig(&tls.Config{ RootCAs: roots }) +// +// // or One can disable security check (https) +// client.SetTLSClientConfig(&tls.Config{ InsecureSkipVerify: true }) +// +// Note: This method overwrites existing `TLSClientConfig`. +func (c *Client) SetTLSClientConfig(config *tls.Config) *Client { + transport, err := c.transport() + if err != nil { + c.log.Errorf("%v", err) + return c + } + transport.TLSClientConfig = config + return c +} + +// SetProxy method sets the Proxy URL and Port for Resty client. +// client.SetProxy("http://proxyserver:8888") +// +// OR Without this `SetProxy` method, you could also set Proxy via environment variable. +// +// Refer to godoc `http.ProxyFromEnvironment`. +func (c *Client) SetProxy(proxyURL string) *Client { + transport, err := c.transport() + if err != nil { + c.log.Errorf("%v", err) + return c + } + + pURL, err := url.Parse(proxyURL) + if err != nil { + c.log.Errorf("%v", err) + return c + } + + c.proxyURL = pURL + transport.Proxy = http.ProxyURL(c.proxyURL) + return c +} + +// RemoveProxy method removes the proxy configuration from Resty client +// client.RemoveProxy() +func (c *Client) RemoveProxy() *Client { + transport, err := c.transport() + if err != nil { + c.log.Errorf("%v", err) + return c + } + c.proxyURL = nil + transport.Proxy = nil + return c +} + +// SetCertificates method helps to set client certificates into Resty conveniently. +func (c *Client) SetCertificates(certs ...tls.Certificate) *Client { + config, err := c.tlsConfig() + if err != nil { + c.log.Errorf("%v", err) + return c + } + config.Certificates = append(config.Certificates, certs...) + return c +} + +// SetRootCertificate method helps to add one or more root certificates into Resty client +// client.SetRootCertificate("/path/to/root/pemFile.pem") +func (c *Client) SetRootCertificate(pemFilePath string) *Client { + rootPemData, err := ioutil.ReadFile(pemFilePath) + if err != nil { + c.log.Errorf("%v", err) + return c + } + + config, err := c.tlsConfig() + if err != nil { + c.log.Errorf("%v", err) + return c + } + if config.RootCAs == nil { + config.RootCAs = x509.NewCertPool() + } + + config.RootCAs.AppendCertsFromPEM(rootPemData) + return c +} + +// SetRootCertificateFromString method helps to add one or more root certificates into Resty client +// client.SetRootCertificateFromString("pem file content") +func (c *Client) SetRootCertificateFromString(pemContent string) *Client { + config, err := c.tlsConfig() + if err != nil { + c.log.Errorf("%v", err) + return c + } + if config.RootCAs == nil { + config.RootCAs = x509.NewCertPool() + } + + config.RootCAs.AppendCertsFromPEM([]byte(pemContent)) + return c +} + +// SetOutputDirectory method sets output directory for saving HTTP response into file. +// If the output directory not exists then resty creates one. This setting is optional one, +// if you're planning using absolute path in `Request.SetOutput` and can used together. +// client.SetOutputDirectory("/save/http/response/here") +func (c *Client) SetOutputDirectory(dirPath string) *Client { + c.outputDirectory = dirPath + return c +} + +// SetTransport method sets custom `*http.Transport` or any `http.RoundTripper` +// compatible interface implementation in the resty client. +// +// Note: +// +// - If transport is not type of `*http.Transport` then you may not be able to +// take advantage of some of the Resty client settings. +// +// - It overwrites the Resty client transport instance and it's configurations. +// +// transport := &http.Transport{ +// // somthing like Proxying to httptest.Server, etc... +// Proxy: func(req *http.Request) (*url.URL, error) { +// return url.Parse(server.URL) +// }, +// } +// +// client.SetTransport(transport) +func (c *Client) SetTransport(transport http.RoundTripper) *Client { + if transport != nil { + c.httpClient.Transport = transport + } + return c +} + +// SetScheme method sets custom scheme in the Resty client. It's way to override default. +// client.SetScheme("http") +func (c *Client) SetScheme(scheme string) *Client { + if !IsStringEmpty(scheme) { + c.scheme = strings.TrimSpace(scheme) + } + return c +} + +// SetCloseConnection method sets variable `Close` in http request struct with the given +// value. More info: https://golang.org/src/net/http/request.go +func (c *Client) SetCloseConnection(close bool) *Client { + c.closeConnection = close + return c +} + +// SetDoNotParseResponse method instructs `Resty` not to parse the response body automatically. +// Resty exposes the raw response body as `io.ReadCloser`. Also do not forget to close the body, +// otherwise you might get into connection leaks, no connection reuse. +// +// Note: Response middlewares are not applicable, if you use this option. Basically you have +// taken over the control of response parsing from `Resty`. +func (c *Client) SetDoNotParseResponse(parse bool) *Client { + c.notParseResponse = parse + return c +} + +// SetPathParam method sets single URL path key-value pair in the +// Resty client instance. +// client.SetPathParam("userId", "sample@sample.com") +// +// Result: +// URL - /v1/users/{userId}/details +// Composed URL - /v1/users/sample@sample.com/details +// It replaces the value of the key while composing the request URL. +// +// Also it can be overridden at request level Path Params options, +// see `Request.SetPathParam` or `Request.SetPathParams`. +func (c *Client) SetPathParam(param, value string) *Client { + c.PathParams[param] = value + return c +} + +// SetPathParams method sets multiple URL path key-value pairs at one go in the +// Resty client instance. +// client.SetPathParams(map[string]string{ +// "userId": "sample@sample.com", +// "subAccountId": "100002", +// }) +// +// Result: +// URL - /v1/users/{userId}/{subAccountId}/details +// Composed URL - /v1/users/sample@sample.com/100002/details +// It replaces the value of the key while composing the request URL. +// +// Also it can be overridden at request level Path Params options, +// see `Request.SetPathParam` or `Request.SetPathParams`. +func (c *Client) SetPathParams(params map[string]string) *Client { + for p, v := range params { + c.SetPathParam(p, v) + } + return c +} + +// SetJSONEscapeHTML method is to enable/disable the HTML escape on JSON marshal. +// +// Note: This option only applicable to standard JSON Marshaller. +func (c *Client) SetJSONEscapeHTML(b bool) *Client { + c.jsonEscapeHTML = b + return c +} + +// EnableTrace method enables the Resty client trace for the requests fired from +// the client using `httptrace.ClientTrace` and provides insights. +// +// client := resty.New().EnableTrace() +// +// resp, err := client.R().Get("https://httpbin.org/get") +// fmt.Println("Error:", err) +// fmt.Println("Trace Info:", resp.Request.TraceInfo()) +// +// Also `Request.EnableTrace` available too to get trace info for single request. +// +// Since v2.0.0 +func (c *Client) EnableTrace() *Client { + c.trace = true + return c +} + +// DisableTrace method disables the Resty client trace. Refer to `Client.EnableTrace`. +// +// Since v2.0.0 +func (c *Client) DisableTrace() *Client { + c.trace = false + return c +} + +// IsProxySet method returns the true is proxy is set from resty client otherwise +// false. By default proxy is set from environment, refer to `http.ProxyFromEnvironment`. +func (c *Client) IsProxySet() bool { + return c.proxyURL != nil +} + +// GetClient method returns the current `http.Client` used by the resty client. +func (c *Client) GetClient() *http.Client { + return c.httpClient +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Client Unexported methods +//_______________________________________________________________________ + +// Executes method executes the given `Request` object and returns response +// error. +func (c *Client) execute(req *Request) (*Response, error) { + // Apply Request middleware + var err error + + // user defined on before request methods + // to modify the *resty.Request object + for _, f := range c.udBeforeRequest { + if err = f(c, req); err != nil { + return nil, wrapNoRetryErr(err) + } + } + + // resty middlewares + for _, f := range c.beforeRequest { + if err = f(c, req); err != nil { + return nil, wrapNoRetryErr(err) + } + } + + if hostHeader := req.Header.Get("Host"); hostHeader != "" { + req.RawRequest.Host = hostHeader + } + + // call pre-request if defined + if c.preReqHook != nil { + if err = c.preReqHook(c, req.RawRequest); err != nil { + return nil, wrapNoRetryErr(err) + } + } + + if err = requestLogger(c, req); err != nil { + return nil, wrapNoRetryErr(err) + } + + req.RawRequest.Body = newRequestBodyReleaser(req.RawRequest.Body, req.bodyBuf) + + req.Time = time.Now() + resp, err := c.httpClient.Do(req.RawRequest) + + response := &Response{ + Request: req, + RawResponse: resp, + } + + if err != nil || req.notParseResponse || c.notParseResponse { + response.setReceivedAt() + return response, err + } + + if !req.isSaveResponse { + defer closeq(resp.Body) + body := resp.Body + + // GitHub #142 & #187 + if strings.EqualFold(resp.Header.Get(hdrContentEncodingKey), "gzip") && resp.ContentLength != 0 { + if _, ok := body.(*gzip.Reader); !ok { + body, err = gzip.NewReader(body) + if err != nil { + response.setReceivedAt() + return response, err + } + defer closeq(body) + } + } + + if response.body, err = ioutil.ReadAll(body); err != nil { + response.setReceivedAt() + return response, err + } + + response.size = int64(len(response.body)) + } + + response.setReceivedAt() // after we read the body + + // Apply Response middleware + for _, f := range c.afterResponse { + if err = f(c, response); err != nil { + break + } + } + + return response, wrapNoRetryErr(err) +} + +// getting TLS client config if not exists then create one +func (c *Client) tlsConfig() (*tls.Config, error) { + transport, err := c.transport() + if err != nil { + return nil, err + } + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } + return transport.TLSClientConfig, nil +} + +// Transport method returns `*http.Transport` currently in use or error +// in case currently used `transport` is not a `*http.Transport`. +func (c *Client) transport() (*http.Transport, error) { + if transport, ok := c.httpClient.Transport.(*http.Transport); ok { + return transport, nil + } + return nil, errors.New("current transport is not an *http.Transport instance") +} + +// just an internal helper method +func (c *Client) outputLogTo(w io.Writer) *Client { + c.log.(*logger).l.SetOutput(w) + return c +} + +// ResponseError is a wrapper for including the server response with an error. +// Neither the err nor the response should be nil. +type ResponseError struct { + Response *Response + Err error +} + +func (e *ResponseError) Error() string { + return e.Err.Error() +} + +func (e *ResponseError) Unwrap() error { + return e.Err +} + +// Helper to run onErrorHooks hooks. +// It wraps the error in a ResponseError if the resp is not nil +// so hooks can access it. +func (c *Client) onErrorHooks(req *Request, resp *Response, err error) { + if err != nil { + if resp != nil { // wrap with ResponseError + err = &ResponseError{Response: resp, Err: err} + } + for _, h := range c.errorHooks { + h(req, err) + } + } +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// File struct and its methods +//_______________________________________________________________________ + +// File struct represent file information for multipart request +type File struct { + Name string + ParamName string + io.Reader +} + +// String returns string value of current file details +func (f *File) String() string { + return fmt.Sprintf("ParamName: %v; FileName: %v", f.ParamName, f.Name) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// MultipartField struct +//_______________________________________________________________________ + +// MultipartField struct represent custom data part for multipart request +type MultipartField struct { + Param string + FileName string + ContentType string + io.Reader +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Unexported package methods +//_______________________________________________________________________ + +func createClient(hc *http.Client) *Client { + if hc.Transport == nil { + hc.Transport = createTransport(nil) + } + + c := &Client{ // not setting lang default values + QueryParam: url.Values{}, + FormData: url.Values{}, + Header: http.Header{}, + Cookies: make([]*http.Cookie, 0), + RetryWaitTime: defaultWaitTime, + RetryMaxWaitTime: defaultMaxWaitTime, + PathParams: make(map[string]string), + JSONMarshal: json.Marshal, + JSONUnmarshal: json.Unmarshal, + XMLMarshal: xml.Marshal, + XMLUnmarshal: xml.Unmarshal, + HeaderAuthorizationKey: http.CanonicalHeaderKey("Authorization"), + + jsonEscapeHTML: true, + httpClient: hc, + debugBodySizeLimit: math.MaxInt32, + } + + // Logger + c.SetLogger(createLogger()) + + // default before request middlewares + c.beforeRequest = []RequestMiddleware{ + parseRequestURL, + parseRequestHeader, + parseRequestBody, + createHTTPRequest, + addCredentials, + } + + // user defined request middlewares + c.udBeforeRequest = []RequestMiddleware{} + + // default after response middlewares + c.afterResponse = []ResponseMiddleware{ + responseLogger, + parseResponseBody, + saveResponseIntoFile, + } + + return c +} diff --git a/vendor/github.com/go-resty/resty/v2/middleware.go b/vendor/github.com/go-resty/resty/v2/middleware.go new file mode 100644 index 000000000..0e8ac2b69 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/middleware.go @@ -0,0 +1,543 @@ +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "time" +) + +const debugRequestLogKey = "__restyDebugRequestLog" + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Request Middleware(s) +//_______________________________________________________________________ + +func parseRequestURL(c *Client, r *Request) error { + // GitHub #103 Path Params + if len(r.PathParams) > 0 { + for p, v := range r.PathParams { + r.URL = strings.Replace(r.URL, "{"+p+"}", url.PathEscape(v), -1) + } + } + if len(c.PathParams) > 0 { + for p, v := range c.PathParams { + r.URL = strings.Replace(r.URL, "{"+p+"}", url.PathEscape(v), -1) + } + } + + // Parsing request URL + reqURL, err := url.Parse(r.URL) + if err != nil { + return err + } + + // If Request.URL is relative path then added c.HostURL into + // the request URL otherwise Request.URL will be used as-is + if !reqURL.IsAbs() { + r.URL = reqURL.String() + if len(r.URL) > 0 && r.URL[0] != '/' { + r.URL = "/" + r.URL + } + + reqURL, err = url.Parse(c.HostURL + r.URL) + if err != nil { + return err + } + } + + // GH #407 && #318 + if reqURL.Scheme == "" && len(c.scheme) > 0 { + reqURL.Scheme = c.scheme + } + + // Adding Query Param + query := make(url.Values) + for k, v := range c.QueryParam { + for _, iv := range v { + query.Add(k, iv) + } + } + + for k, v := range r.QueryParam { + // remove query param from client level by key + // since overrides happens for that key in the request + query.Del(k) + + for _, iv := range v { + query.Add(k, iv) + } + } + + // GitHub #123 Preserve query string order partially. + // Since not feasible in `SetQuery*` resty methods, because + // standard package `url.Encode(...)` sorts the query params + // alphabetically + if len(query) > 0 { + if IsStringEmpty(reqURL.RawQuery) { + reqURL.RawQuery = query.Encode() + } else { + reqURL.RawQuery = reqURL.RawQuery + "&" + query.Encode() + } + } + + r.URL = reqURL.String() + + return nil +} + +func parseRequestHeader(c *Client, r *Request) error { + hdr := make(http.Header) + for k := range c.Header { + hdr[k] = append(hdr[k], c.Header[k]...) + } + + for k := range r.Header { + hdr.Del(k) + hdr[k] = append(hdr[k], r.Header[k]...) + } + + if IsStringEmpty(hdr.Get(hdrUserAgentKey)) { + hdr.Set(hdrUserAgentKey, hdrUserAgentValue) + } + + ct := hdr.Get(hdrContentTypeKey) + if IsStringEmpty(hdr.Get(hdrAcceptKey)) && !IsStringEmpty(ct) && + (IsJSONType(ct) || IsXMLType(ct)) { + hdr.Set(hdrAcceptKey, hdr.Get(hdrContentTypeKey)) + } + + r.Header = hdr + + return nil +} + +func parseRequestBody(c *Client, r *Request) (err error) { + if isPayloadSupported(r.Method, c.AllowGetMethodPayload) { + // Handling Multipart + if r.isMultiPart && !(r.Method == MethodPatch) { + if err = handleMultipart(c, r); err != nil { + return + } + + goto CL + } + + // Handling Form Data + if len(c.FormData) > 0 || len(r.FormData) > 0 { + handleFormData(c, r) + + goto CL + } + + // Handling Request body + if r.Body != nil { + handleContentType(c, r) + + if err = handleRequestBody(c, r); err != nil { + return + } + } + } + +CL: + // by default resty won't set content length, you can if you want to :) + if (c.setContentLength || r.setContentLength) && r.bodyBuf != nil { + r.Header.Set(hdrContentLengthKey, fmt.Sprintf("%d", r.bodyBuf.Len())) + } + + return +} + +func createHTTPRequest(c *Client, r *Request) (err error) { + if r.bodyBuf == nil { + if reader, ok := r.Body.(io.Reader); ok { + r.RawRequest, err = http.NewRequest(r.Method, r.URL, reader) + } else if c.setContentLength || r.setContentLength { + r.RawRequest, err = http.NewRequest(r.Method, r.URL, http.NoBody) + } else { + r.RawRequest, err = http.NewRequest(r.Method, r.URL, nil) + } + } else { + r.RawRequest, err = http.NewRequest(r.Method, r.URL, r.bodyBuf) + } + + if err != nil { + return + } + + // Assign close connection option + r.RawRequest.Close = c.closeConnection + + // Add headers into http request + r.RawRequest.Header = r.Header + + // Add cookies from client instance into http request + for _, cookie := range c.Cookies { + r.RawRequest.AddCookie(cookie) + } + + // Add cookies from request instance into http request + for _, cookie := range r.Cookies { + r.RawRequest.AddCookie(cookie) + } + + // Enable trace + if c.trace || r.trace { + r.clientTrace = &clientTrace{} + r.ctx = r.clientTrace.createContext(r.Context()) + } + + // Use context if it was specified + if r.ctx != nil { + r.RawRequest = r.RawRequest.WithContext(r.ctx) + } + + bodyCopy, err := getBodyCopy(r) + if err != nil { + return err + } + + // assign get body func for the underlying raw request instance + r.RawRequest.GetBody = func() (io.ReadCloser, error) { + if bodyCopy != nil { + return ioutil.NopCloser(bytes.NewReader(bodyCopy.Bytes())), nil + } + return nil, nil + } + + return +} + +func addCredentials(c *Client, r *Request) error { + var isBasicAuth bool + // Basic Auth + if r.UserInfo != nil { // takes precedence + r.RawRequest.SetBasicAuth(r.UserInfo.Username, r.UserInfo.Password) + isBasicAuth = true + } else if c.UserInfo != nil { + r.RawRequest.SetBasicAuth(c.UserInfo.Username, c.UserInfo.Password) + isBasicAuth = true + } + + if !c.DisableWarn { + if isBasicAuth && !strings.HasPrefix(r.URL, "https") { + c.log.Warnf("Using Basic Auth in HTTP mode is not secure, use HTTPS") + } + } + + // Set the Authorization Header Scheme + var authScheme string + if !IsStringEmpty(r.AuthScheme) { + authScheme = r.AuthScheme + } else if !IsStringEmpty(c.AuthScheme) { + authScheme = c.AuthScheme + } else { + authScheme = "Bearer" + } + + // Build the Token Auth header + if !IsStringEmpty(r.Token) { // takes precedence + r.RawRequest.Header.Set(c.HeaderAuthorizationKey, authScheme+" "+r.Token) + } else if !IsStringEmpty(c.Token) { + r.RawRequest.Header.Set(c.HeaderAuthorizationKey, authScheme+" "+c.Token) + } + + return nil +} + +func requestLogger(c *Client, r *Request) error { + if c.Debug { + rr := r.RawRequest + rl := &RequestLog{Header: copyHeaders(rr.Header), Body: r.fmtBodyString(c.debugBodySizeLimit)} + if c.requestLog != nil { + if err := c.requestLog(rl); err != nil { + return err + } + } + // fmt.Sprintf("COOKIES:\n%s\n", composeCookies(c.GetClient().Jar, *rr.URL)) + + + reqLog := "\n==============================================================================\n" + + "~~~ REQUEST ~~~\n" + + fmt.Sprintf("%s %s %s\n", r.Method, rr.URL.RequestURI(), rr.Proto) + + fmt.Sprintf("HOST : %s\n", rr.URL.Host) + + fmt.Sprintf("HEADERS:\n%s\n", composeHeaders(c, r, rl.Header)) + + fmt.Sprintf("BODY :\n%v\n", rl.Body) + + "------------------------------------------------------------------------------\n" + + r.initValuesMap() + r.values[debugRequestLogKey] = reqLog + } + + return nil +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Response Middleware(s) +//_______________________________________________________________________ + +func responseLogger(c *Client, res *Response) error { + if c.Debug { + rl := &ResponseLog{Header: copyHeaders(res.Header()), Body: res.fmtBodyString(c.debugBodySizeLimit)} + if c.responseLog != nil { + if err := c.responseLog(rl); err != nil { + return err + } + } + + debugLog := res.Request.values[debugRequestLogKey].(string) + debugLog += "~~~ RESPONSE ~~~\n" + + fmt.Sprintf("STATUS : %s\n", res.Status()) + + fmt.Sprintf("PROTO : %s\n", res.RawResponse.Proto) + + fmt.Sprintf("RECEIVED AT : %v\n", res.ReceivedAt().Format(time.RFC3339Nano)) + + fmt.Sprintf("TIME DURATION: %v\n", res.Time()) + + "HEADERS :\n" + + composeHeaders(c, res.Request, rl.Header) + "\n" + if res.Request.isSaveResponse { + debugLog += "BODY :\n***** RESPONSE WRITTEN INTO FILE *****\n" + } else { + debugLog += fmt.Sprintf("BODY :\n%v\n", rl.Body) + } + debugLog += "==============================================================================\n" + + c.log.Debugf("%s", debugLog) + } + + return nil +} + +func parseResponseBody(c *Client, res *Response) (err error) { + if res.StatusCode() == http.StatusNoContent { + return + } + // Handles only JSON or XML content type + ct := firstNonEmpty(res.Request.forceContentType, res.Header().Get(hdrContentTypeKey), res.Request.fallbackContentType) + if IsJSONType(ct) || IsXMLType(ct) { + // HTTP status code > 199 and < 300, considered as Result + if res.IsSuccess() { + res.Request.Error = nil + if res.Request.Result != nil { + err = Unmarshalc(c, ct, res.body, res.Request.Result) + return + } + } + + // HTTP status code > 399, considered as Error + if res.IsError() { + // global error interface + if res.Request.Error == nil && c.Error != nil { + res.Request.Error = reflect.New(c.Error).Interface() + } + + if res.Request.Error != nil { + err = Unmarshalc(c, ct, res.body, res.Request.Error) + } + } + } + + return +} + +func handleMultipart(c *Client, r *Request) (err error) { + r.bodyBuf = acquireBuffer() + w := multipart.NewWriter(r.bodyBuf) + + for k, v := range c.FormData { + for _, iv := range v { + if err = w.WriteField(k, iv); err != nil { + return err + } + } + } + + for k, v := range r.FormData { + for _, iv := range v { + if strings.HasPrefix(k, "@") { // file + err = addFile(w, k[1:], iv) + if err != nil { + return + } + } else { // form value + if err = w.WriteField(k, iv); err != nil { + return err + } + } + } + } + + // #21 - adding io.Reader support + if len(r.multipartFiles) > 0 { + for _, f := range r.multipartFiles { + err = addFileReader(w, f) + if err != nil { + return + } + } + } + + // GitHub #130 adding multipart field support with content type + if len(r.multipartFields) > 0 { + for _, mf := range r.multipartFields { + if err = addMultipartFormField(w, mf); err != nil { + return + } + } + } + + r.Header.Set(hdrContentTypeKey, w.FormDataContentType()) + err = w.Close() + + return +} + +func handleFormData(c *Client, r *Request) { + formData := url.Values{} + + for k, v := range c.FormData { + for _, iv := range v { + formData.Add(k, iv) + } + } + + for k, v := range r.FormData { + // remove form data field from client level by key + // since overrides happens for that key in the request + formData.Del(k) + + for _, iv := range v { + formData.Add(k, iv) + } + } + + r.bodyBuf = bytes.NewBuffer([]byte(formData.Encode())) + r.Header.Set(hdrContentTypeKey, formContentType) + r.isFormData = true +} + +func handleContentType(c *Client, r *Request) { + contentType := r.Header.Get(hdrContentTypeKey) + if IsStringEmpty(contentType) { + contentType = DetectContentType(r.Body) + r.Header.Set(hdrContentTypeKey, contentType) + } +} + +func handleRequestBody(c *Client, r *Request) (err error) { + var bodyBytes []byte + contentType := r.Header.Get(hdrContentTypeKey) + kind := kindOf(r.Body) + r.bodyBuf = nil + + if reader, ok := r.Body.(io.Reader); ok { + if c.setContentLength || r.setContentLength { // keep backward compatibility + r.bodyBuf = acquireBuffer() + _, err = r.bodyBuf.ReadFrom(reader) + r.Body = nil + } else { + // Otherwise buffer less processing for `io.Reader`, sounds good. + return + } + } else if b, ok := r.Body.([]byte); ok { + bodyBytes = b + } else if s, ok := r.Body.(string); ok { + bodyBytes = []byte(s) + } else if IsJSONType(contentType) && + (kind == reflect.Struct || kind == reflect.Map || kind == reflect.Slice) { + r.bodyBuf, err = jsonMarshal(c, r, r.Body) + if err != nil { + return + } + } else if IsXMLType(contentType) && (kind == reflect.Struct) { + bodyBytes, err = c.XMLMarshal(r.Body) + if err != nil { + return + } + } + + if bodyBytes == nil && r.bodyBuf == nil { + err = errors.New("unsupported 'Body' type/value") + } + + // if any errors during body bytes handling, return it + if err != nil { + return + } + + // []byte into Buffer + if bodyBytes != nil && r.bodyBuf == nil { + r.bodyBuf = acquireBuffer() + _, _ = r.bodyBuf.Write(bodyBytes) + } + + return +} + +func saveResponseIntoFile(c *Client, res *Response) error { + if res.Request.isSaveResponse { + file := "" + + if len(c.outputDirectory) > 0 && !filepath.IsAbs(res.Request.outputFile) { + file += c.outputDirectory + string(filepath.Separator) + } + + file = filepath.Clean(file + res.Request.outputFile) + if err := createDirectory(filepath.Dir(file)); err != nil { + return err + } + + outFile, err := os.Create(file) + if err != nil { + return err + } + defer closeq(outFile) + + // io.Copy reads maximum 32kb size, it is perfect for large file download too + defer closeq(res.RawResponse.Body) + + written, err := io.Copy(outFile, res.RawResponse.Body) + if err != nil { + return err + } + + res.size = written + } + + return nil +} + +func getBodyCopy(r *Request) (*bytes.Buffer, error) { + // If r.bodyBuf present, return the copy + if r.bodyBuf != nil { + return bytes.NewBuffer(r.bodyBuf.Bytes()), nil + } + + // Maybe body is `io.Reader`. + // Note: Resty user have to watchout for large body size of `io.Reader` + if r.RawRequest.Body != nil { + b, err := ioutil.ReadAll(r.RawRequest.Body) + if err != nil { + return nil, err + } + + // Restore the Body + closeq(r.RawRequest.Body) + r.RawRequest.Body = ioutil.NopCloser(bytes.NewBuffer(b)) + + // Return the Body bytes + return bytes.NewBuffer(b), nil + } + return nil, nil +} diff --git a/vendor/github.com/go-resty/resty/v2/redirect.go b/vendor/github.com/go-resty/resty/v2/redirect.go new file mode 100644 index 000000000..7d7e43bc1 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/redirect.go @@ -0,0 +1,101 @@ +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "errors" + "fmt" + "net" + "net/http" + "strings" +) + +type ( + // RedirectPolicy to regulate the redirects in the resty client. + // Objects implementing the RedirectPolicy interface can be registered as + // + // Apply function should return nil to continue the redirect jounery, otherwise + // return error to stop the redirect. + RedirectPolicy interface { + Apply(req *http.Request, via []*http.Request) error + } + + // The RedirectPolicyFunc type is an adapter to allow the use of ordinary functions as RedirectPolicy. + // If f is a function with the appropriate signature, RedirectPolicyFunc(f) is a RedirectPolicy object that calls f. + RedirectPolicyFunc func(*http.Request, []*http.Request) error +) + +// Apply calls f(req, via). +func (f RedirectPolicyFunc) Apply(req *http.Request, via []*http.Request) error { + return f(req, via) +} + +// NoRedirectPolicy is used to disable redirects in the HTTP client +// resty.SetRedirectPolicy(NoRedirectPolicy()) +func NoRedirectPolicy() RedirectPolicy { + return RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { + return errors.New("auto redirect is disabled") + }) +} + +// FlexibleRedirectPolicy is convenient method to create No of redirect policy for HTTP client. +// resty.SetRedirectPolicy(FlexibleRedirectPolicy(20)) +func FlexibleRedirectPolicy(noOfRedirect int) RedirectPolicy { + return RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { + if len(via) >= noOfRedirect { + return fmt.Errorf("stopped after %d redirects", noOfRedirect) + } + checkHostAndAddHeaders(req, via[0]) + return nil + }) +} + +// DomainCheckRedirectPolicy is convenient method to define domain name redirect rule in resty client. +// Redirect is allowed for only mentioned host in the policy. +// resty.SetRedirectPolicy(DomainCheckRedirectPolicy("host1.com", "host2.org", "host3.net")) +func DomainCheckRedirectPolicy(hostnames ...string) RedirectPolicy { + hosts := make(map[string]bool) + for _, h := range hostnames { + hosts[strings.ToLower(h)] = true + } + + fn := RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { + if ok := hosts[getHostname(req.URL.Host)]; !ok { + return errors.New("redirect is not allowed as per DomainCheckRedirectPolicy") + } + + return nil + }) + + return fn +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Package Unexported methods +//_______________________________________________________________________ + +func getHostname(host string) (hostname string) { + if strings.Index(host, ":") > 0 { + host, _, _ = net.SplitHostPort(host) + } + hostname = strings.ToLower(host) + return +} + +// By default Golang will not redirect request headers +// after go throughing various discussion comments from thread +// https://github.com/golang/go/issues/4800 +// Resty will add all the headers during a redirect for the same host +func checkHostAndAddHeaders(cur *http.Request, pre *http.Request) { + curHostname := getHostname(cur.URL.Host) + preHostname := getHostname(pre.URL.Host) + if strings.EqualFold(curHostname, preHostname) { + for key, val := range pre.Header { + cur.Header[key] = val + } + } else { // only library User-Agent header is added + cur.Header.Set(hdrUserAgentKey, hdrUserAgentValue) + } +} diff --git a/vendor/github.com/go-resty/resty/v2/request.go b/vendor/github.com/go-resty/resty/v2/request.go new file mode 100644 index 000000000..672df88c3 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/request.go @@ -0,0 +1,896 @@ +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "net/url" + "reflect" + "strings" + "time" +) + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Request struct and methods +//_______________________________________________________________________ + +// Request struct is used to compose and fire individual request from +// resty client. Request provides an options to override client level +// settings and also an options for the request composition. +type Request struct { + URL string + Method string + Token string + AuthScheme string + QueryParam url.Values + FormData url.Values + PathParams map[string]string + Header http.Header + Time time.Time + Body interface{} + Result interface{} + Error interface{} + RawRequest *http.Request + SRV *SRVRecord + UserInfo *User + Cookies []*http.Cookie + + // Attempt is to represent the request attempt made during a Resty + // request execution flow, including retry count. + // + // Since v2.4.0 + Attempt int + + isMultiPart bool + isFormData bool + setContentLength bool + isSaveResponse bool + notParseResponse bool + jsonEscapeHTML bool + trace bool + outputFile string + fallbackContentType string + forceContentType string + ctx context.Context + values map[string]interface{} + client *Client + bodyBuf *bytes.Buffer + clientTrace *clientTrace + multipartFiles []*File + multipartFields []*MultipartField + retryConditions []RetryConditionFunc +} + +// Context method returns the Context if its already set in request +// otherwise it creates new one using `context.Background()`. +func (r *Request) Context() context.Context { + if r.ctx == nil { + return context.Background() + } + return r.ctx +} + +// SetContext method sets the context.Context for current Request. It allows +// to interrupt the request execution if ctx.Done() channel is closed. +// See https://blog.golang.org/context article and the "context" package +// documentation. +func (r *Request) SetContext(ctx context.Context) *Request { + r.ctx = ctx + return r +} + +// SetHeader method is to set a single header field and its value in the current request. +// +// For Example: To set `Content-Type` and `Accept` as `application/json`. +// client.R(). +// SetHeader("Content-Type", "application/json"). +// SetHeader("Accept", "application/json") +// +// Also you can override header value, which was set at client instance level. +func (r *Request) SetHeader(header, value string) *Request { + r.Header.Set(header, value) + return r +} + +// SetHeaders method sets multiple headers field and its values at one go in the current request. +// +// For Example: To set `Content-Type` and `Accept` as `application/json` +// +// client.R(). +// SetHeaders(map[string]string{ +// "Content-Type": "application/json", +// "Accept": "application/json", +// }) +// Also you can override header value, which was set at client instance level. +func (r *Request) SetHeaders(headers map[string]string) *Request { + for h, v := range headers { + r.SetHeader(h, v) + } + return r +} + +// SetHeaderMultiValues sets multiple headers fields and its values is list of strings at one go in the current request. +// +// For Example: To set `Accept` as `text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8` +// +// client.R(). +// SetHeaderMultiValues(map[string][]string{ +// "Accept": []string{"text/html", "application/xhtml+xml", "application/xml;q=0.9", "image/webp", "*/*;q=0.8"}, +// }) +// Also you can override header value, which was set at client instance level. +func (r *Request) SetHeaderMultiValues(headers map[string][]string) *Request { + for key, values := range headers { + r.SetHeader(key, strings.Join(values, ", ")) + } + return r +} + +// SetHeaderVerbatim method is to set a single header field and its value verbatim in the current request. +// +// For Example: To set `all_lowercase` and `UPPERCASE` as `available`. +// client.R(). +// SetHeaderVerbatim("all_lowercase", "available"). +// SetHeaderVerbatim("UPPERCASE", "available") +// +// Also you can override header value, which was set at client instance level. +// +// Since v2.6.0 +func (r *Request) SetHeaderVerbatim(header, value string) *Request { + r.Header[header] = []string{value} + return r +} + +// SetQueryParam method sets single parameter and its value in the current request. +// It will be formed as query string for the request. +// +// For Example: `search=kitchen%20papers&size=large` in the URL after `?` mark. +// client.R(). +// SetQueryParam("search", "kitchen papers"). +// SetQueryParam("size", "large") +// Also you can override query params value, which was set at client instance level. +func (r *Request) SetQueryParam(param, value string) *Request { + r.QueryParam.Set(param, value) + return r +} + +// SetQueryParams method sets multiple parameters and its values at one go in the current request. +// It will be formed as query string for the request. +// +// For Example: `search=kitchen%20papers&size=large` in the URL after `?` mark. +// client.R(). +// SetQueryParams(map[string]string{ +// "search": "kitchen papers", +// "size": "large", +// }) +// Also you can override query params value, which was set at client instance level. +func (r *Request) SetQueryParams(params map[string]string) *Request { + for p, v := range params { + r.SetQueryParam(p, v) + } + return r +} + +// SetQueryParamsFromValues method appends multiple parameters with multi-value +// (`url.Values`) at one go in the current request. It will be formed as +// query string for the request. +// +// For Example: `status=pending&status=approved&status=open` in the URL after `?` mark. +// client.R(). +// SetQueryParamsFromValues(url.Values{ +// "status": []string{"pending", "approved", "open"}, +// }) +// Also you can override query params value, which was set at client instance level. +func (r *Request) SetQueryParamsFromValues(params url.Values) *Request { + for p, v := range params { + for _, pv := range v { + r.QueryParam.Add(p, pv) + } + } + return r +} + +// SetQueryString method provides ability to use string as an input to set URL query string for the request. +// +// Using String as an input +// client.R(). +// SetQueryString("productId=232&template=fresh-sample&cat=resty&source=google&kw=buy a lot more") +func (r *Request) SetQueryString(query string) *Request { + params, err := url.ParseQuery(strings.TrimSpace(query)) + if err == nil { + for p, v := range params { + for _, pv := range v { + r.QueryParam.Add(p, pv) + } + } + } else { + r.client.log.Errorf("%v", err) + } + return r +} + +// SetFormData method sets Form parameters and their values in the current request. +// It's applicable only HTTP method `POST` and `PUT` and requests content type would be set as +// `application/x-www-form-urlencoded`. +// client.R(). +// SetFormData(map[string]string{ +// "access_token": "BC594900-518B-4F7E-AC75-BD37F019E08F", +// "user_id": "3455454545", +// }) +// Also you can override form data value, which was set at client instance level. +func (r *Request) SetFormData(data map[string]string) *Request { + for k, v := range data { + r.FormData.Set(k, v) + } + return r +} + +// SetFormDataFromValues method appends multiple form parameters with multi-value +// (`url.Values`) at one go in the current request. +// client.R(). +// SetFormDataFromValues(url.Values{ +// "search_criteria": []string{"book", "glass", "pencil"}, +// }) +// Also you can override form data value, which was set at client instance level. +func (r *Request) SetFormDataFromValues(data url.Values) *Request { + for k, v := range data { + for _, kv := range v { + r.FormData.Add(k, kv) + } + } + return r +} + +// SetBody method sets the request body for the request. It supports various realtime needs as easy. +// We can say its quite handy or powerful. Supported request body data types is `string`, +// `[]byte`, `struct`, `map`, `slice` and `io.Reader`. Body value can be pointer or non-pointer. +// Automatic marshalling for JSON and XML content type, if it is `struct`, `map`, or `slice`. +// +// Note: `io.Reader` is processed as bufferless mode while sending request. +// +// For Example: Struct as a body input, based on content type, it will be marshalled. +// client.R(). +// SetBody(User{ +// Username: "jeeva@myjeeva.com", +// Password: "welcome2resty", +// }) +// +// Map as a body input, based on content type, it will be marshalled. +// client.R(). +// SetBody(map[string]interface{}{ +// "username": "jeeva@myjeeva.com", +// "password": "welcome2resty", +// "address": &Address{ +// Address1: "1111 This is my street", +// Address2: "Apt 201", +// City: "My City", +// State: "My State", +// ZipCode: 00000, +// }, +// }) +// +// String as a body input. Suitable for any need as a string input. +// client.R(). +// SetBody(`{ +// "username": "jeeva@getrightcare.com", +// "password": "admin" +// }`) +// +// []byte as a body input. Suitable for raw request such as file upload, serialize & deserialize, etc. +// client.R(). +// SetBody([]byte("This is my raw request, sent as-is")) +func (r *Request) SetBody(body interface{}) *Request { + r.Body = body + return r +} + +// SetResult method is to register the response `Result` object for automatic unmarshalling for the request, +// if response status code is between 200 and 299 and content type either JSON or XML. +// +// Note: Result object can be pointer or non-pointer. +// client.R().SetResult(&AuthToken{}) +// // OR +// client.R().SetResult(AuthToken{}) +// +// Accessing a result value from response instance. +// response.Result().(*AuthToken) +func (r *Request) SetResult(res interface{}) *Request { + r.Result = getPointer(res) + return r +} + +// SetError method is to register the request `Error` object for automatic unmarshalling for the request, +// if response status code is greater than 399 and content type either JSON or XML. +// +// Note: Error object can be pointer or non-pointer. +// client.R().SetError(&AuthError{}) +// // OR +// client.R().SetError(AuthError{}) +// +// Accessing a error value from response instance. +// response.Error().(*AuthError) +func (r *Request) SetError(err interface{}) *Request { + r.Error = getPointer(err) + return r +} + +// SetFile method is to set single file field name and its path for multipart upload. +// client.R(). +// SetFile("my_file", "/Users/jeeva/Gas Bill - Sep.pdf") +func (r *Request) SetFile(param, filePath string) *Request { + r.isMultiPart = true + r.FormData.Set("@"+param, filePath) + return r +} + +// SetFiles method is to set multiple file field name and its path for multipart upload. +// client.R(). +// SetFiles(map[string]string{ +// "my_file1": "/Users/jeeva/Gas Bill - Sep.pdf", +// "my_file2": "/Users/jeeva/Electricity Bill - Sep.pdf", +// "my_file3": "/Users/jeeva/Water Bill - Sep.pdf", +// }) +func (r *Request) SetFiles(files map[string]string) *Request { + r.isMultiPart = true + for f, fp := range files { + r.FormData.Set("@"+f, fp) + } + return r +} + +// SetFileReader method is to set single file using io.Reader for multipart upload. +// client.R(). +// SetFileReader("profile_img", "my-profile-img.png", bytes.NewReader(profileImgBytes)). +// SetFileReader("notes", "user-notes.txt", bytes.NewReader(notesBytes)) +func (r *Request) SetFileReader(param, fileName string, reader io.Reader) *Request { + r.isMultiPart = true + r.multipartFiles = append(r.multipartFiles, &File{ + Name: fileName, + ParamName: param, + Reader: reader, + }) + return r +} + +// SetMultipartFormData method allows simple form data to be attached to the request as `multipart:form-data` +func (r *Request) SetMultipartFormData(data map[string]string) *Request { + for k, v := range data { + r = r.SetMultipartField(k, "", "", strings.NewReader(v)) + } + + return r +} + +// SetMultipartField method is to set custom data using io.Reader for multipart upload. +func (r *Request) SetMultipartField(param, fileName, contentType string, reader io.Reader) *Request { + r.isMultiPart = true + r.multipartFields = append(r.multipartFields, &MultipartField{ + Param: param, + FileName: fileName, + ContentType: contentType, + Reader: reader, + }) + return r +} + +// SetMultipartFields method is to set multiple data fields using io.Reader for multipart upload. +// +// For Example: +// client.R().SetMultipartFields( +// &resty.MultipartField{ +// Param: "uploadManifest1", +// FileName: "upload-file-1.json", +// ContentType: "application/json", +// Reader: strings.NewReader(`{"input": {"name": "Uploaded document 1", "_filename" : ["file1.txt"]}}`), +// }, +// &resty.MultipartField{ +// Param: "uploadManifest2", +// FileName: "upload-file-2.json", +// ContentType: "application/json", +// Reader: strings.NewReader(`{"input": {"name": "Uploaded document 2", "_filename" : ["file2.txt"]}}`), +// }) +// +// If you have slice already, then simply call- +// client.R().SetMultipartFields(fields...) +func (r *Request) SetMultipartFields(fields ...*MultipartField) *Request { + r.isMultiPart = true + r.multipartFields = append(r.multipartFields, fields...) + return r +} + +// SetContentLength method sets the HTTP header `Content-Length` value for current request. +// By default Resty won't set `Content-Length`. Also you have an option to enable for every +// request. +// +// See `Client.SetContentLength` +// client.R().SetContentLength(true) +func (r *Request) SetContentLength(l bool) *Request { + r.setContentLength = l + return r +} + +// SetBasicAuth method sets the basic authentication header in the current HTTP request. +// +// For Example: +// Authorization: Basic +// +// To set the header for username "go-resty" and password "welcome" +// client.R().SetBasicAuth("go-resty", "welcome") +// +// This method overrides the credentials set by method `Client.SetBasicAuth`. +func (r *Request) SetBasicAuth(username, password string) *Request { + r.UserInfo = &User{Username: username, Password: password} + return r +} + +// SetAuthToken method sets the auth token header(Default Scheme: Bearer) in the current HTTP request. Header example: +// Authorization: Bearer +// +// For Example: To set auth token BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F +// +// client.R().SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F") +// +// This method overrides the Auth token set by method `Client.SetAuthToken`. +func (r *Request) SetAuthToken(token string) *Request { + r.Token = token + return r +} + +// SetAuthScheme method sets the auth token scheme type in the HTTP request. For Example: +// Authorization: +// +// For Example: To set the scheme to use OAuth +// +// client.R().SetAuthScheme("OAuth") +// +// This auth header scheme gets added to all the request rasied from this client instance. +// Also it can be overridden or set one at the request level is supported. +// +// Information about Auth schemes can be found in RFC7235 which is linked to below along with the page containing +// the currently defined official authentication schemes: +// https://tools.ietf.org/html/rfc7235 +// https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes +// +// This method overrides the Authorization scheme set by method `Client.SetAuthScheme`. +func (r *Request) SetAuthScheme(scheme string) *Request { + r.AuthScheme = scheme + return r +} + +// SetOutput method sets the output file for current HTTP request. Current HTTP response will be +// saved into given file. It is similar to `curl -o` flag. Absolute path or relative path can be used. +// If is it relative path then output file goes under the output directory, as mentioned +// in the `Client.SetOutputDirectory`. +// client.R(). +// SetOutput("/Users/jeeva/Downloads/ReplyWithHeader-v5.1-beta.zip"). +// Get("http://bit.ly/1LouEKr") +// +// Note: In this scenario `Response.Body` might be nil. +func (r *Request) SetOutput(file string) *Request { + r.outputFile = file + r.isSaveResponse = true + return r +} + +// SetSRV method sets the details to query the service SRV record and execute the +// request. +// client.R(). +// SetSRV(SRVRecord{"web", "testservice.com"}). +// Get("/get") +func (r *Request) SetSRV(srv *SRVRecord) *Request { + r.SRV = srv + return r +} + +// SetDoNotParseResponse method instructs `Resty` not to parse the response body automatically. +// Resty exposes the raw response body as `io.ReadCloser`. Also do not forget to close the body, +// otherwise you might get into connection leaks, no connection reuse. +// +// Note: Response middlewares are not applicable, if you use this option. Basically you have +// taken over the control of response parsing from `Resty`. +func (r *Request) SetDoNotParseResponse(parse bool) *Request { + r.notParseResponse = parse + return r +} + +// SetPathParam method sets single URL path key-value pair in the +// Resty current request instance. +// client.R().SetPathParam("userId", "sample@sample.com") +// +// Result: +// URL - /v1/users/{userId}/details +// Composed URL - /v1/users/sample@sample.com/details +// It replaces the value of the key while composing the request URL. Also you can +// override Path Params value, which was set at client instance level. +func (r *Request) SetPathParam(param, value string) *Request { + r.PathParams[param] = value + return r +} + +// SetPathParams method sets multiple URL path key-value pairs at one go in the +// Resty current request instance. +// client.R().SetPathParams(map[string]string{ +// "userId": "sample@sample.com", +// "subAccountId": "100002", +// }) +// +// Result: +// URL - /v1/users/{userId}/{subAccountId}/details +// Composed URL - /v1/users/sample@sample.com/100002/details +// It replaces the value of the key while composing request URL. Also you can +// override Path Params value, which was set at client instance level. +func (r *Request) SetPathParams(params map[string]string) *Request { + for p, v := range params { + r.SetPathParam(p, v) + } + return r +} + +// ExpectContentType method allows to provide fallback `Content-Type` for automatic unmarshalling +// when `Content-Type` response header is unavailable. +func (r *Request) ExpectContentType(contentType string) *Request { + r.fallbackContentType = contentType + return r +} + +// ForceContentType method provides a strong sense of response `Content-Type` for automatic unmarshalling. +// Resty gives this a higher priority than the `Content-Type` response header. This means that if both +// `Request.ForceContentType` is set and the response `Content-Type` is available, `ForceContentType` will win. +func (r *Request) ForceContentType(contentType string) *Request { + r.forceContentType = contentType + return r +} + +// SetJSONEscapeHTML method is to enable/disable the HTML escape on JSON marshal. +// +// Note: This option only applicable to standard JSON Marshaller. +func (r *Request) SetJSONEscapeHTML(b bool) *Request { + r.jsonEscapeHTML = b + return r +} + +// SetCookie method appends a single cookie in the current request instance. +// client.R().SetCookie(&http.Cookie{ +// Name:"go-resty", +// Value:"This is cookie value", +// }) +// +// Note: Method appends the Cookie value into existing Cookie if already existing. +// +// Since v2.1.0 +func (r *Request) SetCookie(hc *http.Cookie) *Request { + r.Cookies = append(r.Cookies, hc) + return r +} + +// SetCookies method sets an array of cookies in the current request instance. +// cookies := []*http.Cookie{ +// &http.Cookie{ +// Name:"go-resty-1", +// Value:"This is cookie 1 value", +// }, +// &http.Cookie{ +// Name:"go-resty-2", +// Value:"This is cookie 2 value", +// }, +// } +// +// // Setting a cookies into resty's current request +// client.R().SetCookies(cookies) +// +// Note: Method appends the Cookie value into existing Cookie if already existing. +// +// Since v2.1.0 +func (r *Request) SetCookies(rs []*http.Cookie) *Request { + r.Cookies = append(r.Cookies, rs...) + return r +} + +// AddRetryCondition method adds a retry condition function to the request's +// array of functions that are checked to determine if the request is retried. +// The request will retry if any of the functions return true and error is nil. +// +// Note: These retry conditions are checked before all retry conditions of the client. +// +// Since v2.7.0 +func (r *Request) AddRetryCondition(condition RetryConditionFunc) *Request { + r.retryConditions = append(r.retryConditions, condition) + return r +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// HTTP request tracing +//_______________________________________________________________________ + +// EnableTrace method enables trace for the current request +// using `httptrace.ClientTrace` and provides insights. +// +// client := resty.New() +// +// resp, err := client.R().EnableTrace().Get("https://httpbin.org/get") +// fmt.Println("Error:", err) +// fmt.Println("Trace Info:", resp.Request.TraceInfo()) +// +// See `Client.EnableTrace` available too to get trace info for all requests. +// +// Since v2.0.0 +func (r *Request) EnableTrace() *Request { + r.trace = true + return r +} + +// TraceInfo method returns the trace info for the request. +// If either the Client or Request EnableTrace function has not been called +// prior to the request being made, an empty TraceInfo object will be returned. +// +// Since v2.0.0 +func (r *Request) TraceInfo() TraceInfo { + ct := r.clientTrace + + if ct == nil { + return TraceInfo{} + } + + ti := TraceInfo{ + DNSLookup: ct.dnsDone.Sub(ct.dnsStart), + TLSHandshake: ct.tlsHandshakeDone.Sub(ct.tlsHandshakeStart), + ServerTime: ct.gotFirstResponseByte.Sub(ct.gotConn), + IsConnReused: ct.gotConnInfo.Reused, + IsConnWasIdle: ct.gotConnInfo.WasIdle, + ConnIdleTime: ct.gotConnInfo.IdleTime, + RequestAttempt: r.Attempt, + } + + // Calculate the total time accordingly, + // when connection is reused + if ct.gotConnInfo.Reused { + ti.TotalTime = ct.endTime.Sub(ct.getConn) + } else { + ti.TotalTime = ct.endTime.Sub(ct.dnsStart) + } + + // Only calculate on successful connections + if !ct.connectDone.IsZero() { + ti.TCPConnTime = ct.connectDone.Sub(ct.dnsDone) + } + + // Only calculate on successful connections + if !ct.gotConn.IsZero() { + ti.ConnTime = ct.gotConn.Sub(ct.getConn) + } + + // Only calculate on successful connections + if !ct.gotFirstResponseByte.IsZero() { + ti.ResponseTime = ct.endTime.Sub(ct.gotFirstResponseByte) + } + + // Capture remote address info when connection is non-nil + if ct.gotConnInfo.Conn != nil { + ti.RemoteAddr = ct.gotConnInfo.Conn.RemoteAddr() + } + + return ti +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// HTTP verb method starts here +//_______________________________________________________________________ + +// Get method does GET HTTP request. It's defined in section 4.3.1 of RFC7231. +func (r *Request) Get(url string) (*Response, error) { + return r.Execute(MethodGet, url) +} + +// Head method does HEAD HTTP request. It's defined in section 4.3.2 of RFC7231. +func (r *Request) Head(url string) (*Response, error) { + return r.Execute(MethodHead, url) +} + +// Post method does POST HTTP request. It's defined in section 4.3.3 of RFC7231. +func (r *Request) Post(url string) (*Response, error) { + return r.Execute(MethodPost, url) +} + +// Put method does PUT HTTP request. It's defined in section 4.3.4 of RFC7231. +func (r *Request) Put(url string) (*Response, error) { + return r.Execute(MethodPut, url) +} + +// Delete method does DELETE HTTP request. It's defined in section 4.3.5 of RFC7231. +func (r *Request) Delete(url string) (*Response, error) { + return r.Execute(MethodDelete, url) +} + +// Options method does OPTIONS HTTP request. It's defined in section 4.3.7 of RFC7231. +func (r *Request) Options(url string) (*Response, error) { + return r.Execute(MethodOptions, url) +} + +// Patch method does PATCH HTTP request. It's defined in section 2 of RFC5789. +func (r *Request) Patch(url string) (*Response, error) { + return r.Execute(MethodPatch, url) +} + +// Send method performs the HTTP request using the method and URL already defined +// for current `Request`. +// req := client.R() +// req.Method = resty.GET +// req.URL = "http://httpbin.org/get" +// resp, err := client.R().Send() +func (r *Request) Send() (*Response, error) { + return r.Execute(r.Method, r.URL) +} + +// Execute method performs the HTTP request with given HTTP method and URL +// for current `Request`. +// resp, err := client.R().Execute(resty.GET, "http://httpbin.org/get") +func (r *Request) Execute(method, url string) (*Response, error) { + var addrs []*net.SRV + var resp *Response + var err error + + if r.isMultiPart && !(method == MethodPost || method == MethodPut || method == MethodPatch) { + // No OnError hook here since this is a request validation error + return nil, fmt.Errorf("multipart content is not allowed in HTTP verb [%v]", method) + } + + if r.SRV != nil { + _, addrs, err = net.LookupSRV(r.SRV.Service, "tcp", r.SRV.Domain) + if err != nil { + r.client.onErrorHooks(r, nil, err) + return nil, err + } + } + + r.Method = method + r.URL = r.selectAddr(addrs, url, 0) + + if r.client.RetryCount == 0 { + r.Attempt = 1 + resp, err = r.client.execute(r) + r.client.onErrorHooks(r, resp, unwrapNoRetryErr(err)) + return resp, unwrapNoRetryErr(err) + } + + err = Backoff( + func() (*Response, error) { + r.Attempt++ + + r.URL = r.selectAddr(addrs, url, r.Attempt) + + resp, err = r.client.execute(r) + if err != nil { + r.client.log.Errorf("%v, Attempt %v", err, r.Attempt) + } + + return resp, err + }, + Retries(r.client.RetryCount), + WaitTime(r.client.RetryWaitTime), + MaxWaitTime(r.client.RetryMaxWaitTime), + RetryConditions(append(r.retryConditions, r.client.RetryConditions...)), + RetryHooks(r.client.RetryHooks), + ) + + r.client.onErrorHooks(r, resp, unwrapNoRetryErr(err)) + + return resp, unwrapNoRetryErr(err) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// SRVRecord struct +//_______________________________________________________________________ + +// SRVRecord struct holds the data to query the SRV record for the +// following service. +type SRVRecord struct { + Service string + Domain string +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Request Unexported methods +//_______________________________________________________________________ + +func (r *Request) fmtBodyString(sl int64) (body string) { + body = "***** NO CONTENT *****" + if !isPayloadSupported(r.Method, r.client.AllowGetMethodPayload) { + return + } + + if _, ok := r.Body.(io.Reader); ok { + body = "***** BODY IS io.Reader *****" + return + } + + // multipart or form-data + if r.isMultiPart || r.isFormData { + bodySize := int64(r.bodyBuf.Len()) + if bodySize > sl { + body = fmt.Sprintf("***** REQUEST TOO LARGE (size - %d) *****", bodySize) + return + } + body = r.bodyBuf.String() + return + } + + // request body data + if r.Body == nil { + return + } + var prtBodyBytes []byte + var err error + + contentType := r.Header.Get(hdrContentTypeKey) + kind := kindOf(r.Body) + if canJSONMarshal(contentType, kind) { + prtBodyBytes, err = json.MarshalIndent(&r.Body, "", " ") + } else if IsXMLType(contentType) && (kind == reflect.Struct) { + prtBodyBytes, err = xml.MarshalIndent(&r.Body, "", " ") + } else if b, ok := r.Body.(string); ok { + if IsJSONType(contentType) { + bodyBytes := []byte(b) + out := acquireBuffer() + defer releaseBuffer(out) + if err = json.Indent(out, bodyBytes, "", " "); err == nil { + prtBodyBytes = out.Bytes() + } + } else { + body = b + } + } else if b, ok := r.Body.([]byte); ok { + body = fmt.Sprintf("***** BODY IS byte(s) (size - %d) *****", len(b)) + return + } + + if prtBodyBytes != nil && err == nil { + body = string(prtBodyBytes) + } + + if len(body) > 0 { + bodySize := int64(len([]byte(body))) + if bodySize > sl { + body = fmt.Sprintf("***** REQUEST TOO LARGE (size - %d) *****", bodySize) + } + } + + return +} + +func (r *Request) selectAddr(addrs []*net.SRV, path string, attempt int) string { + if addrs == nil { + return path + } + + idx := attempt % len(addrs) + domain := strings.TrimRight(addrs[idx].Target, ".") + path = strings.TrimLeft(path, "/") + + return fmt.Sprintf("%s://%s:%d/%s", r.client.scheme, domain, addrs[idx].Port, path) +} + +func (r *Request) initValuesMap() { + if r.values == nil { + r.values = make(map[string]interface{}) + } +} + +var noescapeJSONMarshal = func(v interface{}) (*bytes.Buffer, error) { + buf := acquireBuffer() + encoder := json.NewEncoder(buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(v); err != nil { + releaseBuffer(buf) + return nil, err + } + + return buf, nil +} diff --git a/vendor/github.com/go-resty/resty/v2/response.go b/vendor/github.com/go-resty/resty/v2/response.go new file mode 100644 index 000000000..8ae0e10ba --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/response.go @@ -0,0 +1,175 @@ +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Response struct and methods +//_______________________________________________________________________ + +// Response struct holds response values of executed request. +type Response struct { + Request *Request + RawResponse *http.Response + + body []byte + size int64 + receivedAt time.Time +} + +// Body method returns HTTP response as []byte array for the executed request. +// +// Note: `Response.Body` might be nil, if `Request.SetOutput` is used. +func (r *Response) Body() []byte { + if r.RawResponse == nil { + return []byte{} + } + return r.body +} + +// Status method returns the HTTP status string for the executed request. +// Example: 200 OK +func (r *Response) Status() string { + if r.RawResponse == nil { + return "" + } + return r.RawResponse.Status +} + +// StatusCode method returns the HTTP status code for the executed request. +// Example: 200 +func (r *Response) StatusCode() int { + if r.RawResponse == nil { + return 0 + } + return r.RawResponse.StatusCode +} + +// Proto method returns the HTTP response protocol used for the request. +func (r *Response) Proto() string { + if r.RawResponse == nil { + return "" + } + return r.RawResponse.Proto +} + +// Result method returns the response value as an object if it has one +func (r *Response) Result() interface{} { + return r.Request.Result +} + +// Error method returns the error object if it has one +func (r *Response) Error() interface{} { + return r.Request.Error +} + +// Header method returns the response headers +func (r *Response) Header() http.Header { + if r.RawResponse == nil { + return http.Header{} + } + return r.RawResponse.Header +} + +// Cookies method to access all the response cookies +func (r *Response) Cookies() []*http.Cookie { + if r.RawResponse == nil { + return make([]*http.Cookie, 0) + } + return r.RawResponse.Cookies() +} + +// String method returns the body of the server response as String. +func (r *Response) String() string { + if r.body == nil { + return "" + } + return strings.TrimSpace(string(r.body)) +} + +// Time method returns the time of HTTP response time that from request we sent and received a request. +// +// See `Response.ReceivedAt` to know when client received response and see `Response.Request.Time` to know +// when client sent a request. +func (r *Response) Time() time.Duration { + if r.Request.clientTrace != nil { + return r.Request.TraceInfo().TotalTime + } + return r.receivedAt.Sub(r.Request.Time) +} + +// ReceivedAt method returns when response got received from server for the request. +func (r *Response) ReceivedAt() time.Time { + return r.receivedAt +} + +// Size method returns the HTTP response size in bytes. Ya, you can relay on HTTP `Content-Length` header, +// however it won't be good for chucked transfer/compressed response. Since Resty calculates response size +// at the client end. You will get actual size of the http response. +func (r *Response) Size() int64 { + return r.size +} + +// RawBody method exposes the HTTP raw response body. Use this method in-conjunction with `SetDoNotParseResponse` +// option otherwise you get an error as `read err: http: read on closed response body`. +// +// Do not forget to close the body, otherwise you might get into connection leaks, no connection reuse. +// Basically you have taken over the control of response parsing from `Resty`. +func (r *Response) RawBody() io.ReadCloser { + if r.RawResponse == nil { + return nil + } + return r.RawResponse.Body +} + +// IsSuccess method returns true if HTTP status `code >= 200 and <= 299` otherwise false. +func (r *Response) IsSuccess() bool { + return r.StatusCode() > 199 && r.StatusCode() < 300 +} + +// IsError method returns true if HTTP status `code >= 400` otherwise false. +func (r *Response) IsError() bool { + return r.StatusCode() > 399 +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Response Unexported methods +//_______________________________________________________________________ + +func (r *Response) setReceivedAt() { + r.receivedAt = time.Now() + if r.Request.clientTrace != nil { + r.Request.clientTrace.endTime = r.receivedAt + } +} + +func (r *Response) fmtBodyString(sl int64) string { + if r.body != nil { + if int64(len(r.body)) > sl { + return fmt.Sprintf("***** RESPONSE TOO LARGE (size - %d) *****", len(r.body)) + } + ct := r.Header().Get(hdrContentTypeKey) + if IsJSONType(ct) { + out := acquireBuffer() + defer releaseBuffer(out) + err := json.Indent(out, r.body, "", " ") + if err != nil { + return fmt.Sprintf("*** Error: Unable to format response body - \"%s\" ***\n\nLog Body as-is:\n%s", err, r.String()) + } + return out.String() + } + return r.String() + } + + return "***** NO CONTENT *****" +} diff --git a/vendor/github.com/go-resty/resty/v2/resty.go b/vendor/github.com/go-resty/resty/v2/resty.go new file mode 100644 index 000000000..6f9c8b4cd --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/resty.go @@ -0,0 +1,40 @@ +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +// Package resty provides Simple HTTP and REST client library for Go. +package resty + +import ( + "net" + "net/http" + "net/http/cookiejar" + + "golang.org/x/net/publicsuffix" +) + +// Version # of resty +const Version = "2.7.0" + +// New method creates a new Resty client. +func New() *Client { + cookieJar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + return createClient(&http.Client{ + Jar: cookieJar, + }) +} + +// NewWithClient method creates a new Resty client with given `http.Client`. +func NewWithClient(hc *http.Client) *Client { + return createClient(hc) +} + +// NewWithLocalAddr method creates a new Resty client with given Local Address +// to dial from. +func NewWithLocalAddr(localAddr net.Addr) *Client { + cookieJar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + return createClient(&http.Client{ + Jar: cookieJar, + Transport: createTransport(localAddr), + }) +} diff --git a/vendor/github.com/go-resty/resty/v2/retry.go b/vendor/github.com/go-resty/resty/v2/retry.go new file mode 100644 index 000000000..00b8514a5 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/retry.go @@ -0,0 +1,221 @@ +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "context" + "math" + "math/rand" + "sync" + "time" +) + +const ( + defaultMaxRetries = 3 + defaultWaitTime = time.Duration(100) * time.Millisecond + defaultMaxWaitTime = time.Duration(2000) * time.Millisecond +) + +type ( + // Option is to create convenient retry options like wait time, max retries, etc. + Option func(*Options) + + // RetryConditionFunc type is for retry condition function + // input: non-nil Response OR request execution error + RetryConditionFunc func(*Response, error) bool + + // OnRetryFunc is for side-effecting functions triggered on retry + OnRetryFunc func(*Response, error) + + // RetryAfterFunc returns time to wait before retry + // For example, it can parse HTTP Retry-After header + // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + // Non-nil error is returned if it is found that request is not retryable + // (0, nil) is a special result means 'use default algorithm' + RetryAfterFunc func(*Client, *Response) (time.Duration, error) + + // Options struct is used to hold retry settings. + Options struct { + maxRetries int + waitTime time.Duration + maxWaitTime time.Duration + retryConditions []RetryConditionFunc + retryHooks []OnRetryFunc + } +) + +// Retries sets the max number of retries +func Retries(value int) Option { + return func(o *Options) { + o.maxRetries = value + } +} + +// WaitTime sets the default wait time to sleep between requests +func WaitTime(value time.Duration) Option { + return func(o *Options) { + o.waitTime = value + } +} + +// MaxWaitTime sets the max wait time to sleep between requests +func MaxWaitTime(value time.Duration) Option { + return func(o *Options) { + o.maxWaitTime = value + } +} + +// RetryConditions sets the conditions that will be checked for retry. +func RetryConditions(conditions []RetryConditionFunc) Option { + return func(o *Options) { + o.retryConditions = conditions + } +} + +// RetryHooks sets the hooks that will be executed after each retry +func RetryHooks(hooks []OnRetryFunc) Option { + return func(o *Options) { + o.retryHooks = hooks + } +} + +// Backoff retries with increasing timeout duration up until X amount of retries +// (Default is 3 attempts, Override with option Retries(n)) +func Backoff(operation func() (*Response, error), options ...Option) error { + // Defaults + opts := Options{ + maxRetries: defaultMaxRetries, + waitTime: defaultWaitTime, + maxWaitTime: defaultMaxWaitTime, + retryConditions: []RetryConditionFunc{}, + } + + for _, o := range options { + o(&opts) + } + + var ( + resp *Response + err error + ) + + for attempt := 0; attempt <= opts.maxRetries; attempt++ { + resp, err = operation() + ctx := context.Background() + if resp != nil && resp.Request.ctx != nil { + ctx = resp.Request.ctx + } + if ctx.Err() != nil { + return err + } + + err1 := unwrapNoRetryErr(err) // raw error, it used for return users callback. + needsRetry := err != nil && err == err1 // retry on a few operation errors by default + + for _, condition := range opts.retryConditions { + needsRetry = condition(resp, err1) + if needsRetry { + break + } + } + + if !needsRetry { + return err + } + + for _, hook := range opts.retryHooks { + hook(resp, err) + } + + // Don't need to wait when no retries left. + // Still run retry hooks even on last retry to keep compatibility. + if attempt == opts.maxRetries { + return err + } + + waitTime, err2 := sleepDuration(resp, opts.waitTime, opts.maxWaitTime, attempt) + if err2 != nil { + if err == nil { + err = err2 + } + return err + } + + select { + case <-time.After(waitTime): + case <-ctx.Done(): + return ctx.Err() + } + } + + return err +} + +func sleepDuration(resp *Response, min, max time.Duration, attempt int) (time.Duration, error) { + const maxInt = 1<<31 - 1 // max int for arch 386 + if max < 0 { + max = maxInt + } + if resp == nil { + return jitterBackoff(min, max, attempt), nil + } + + retryAfterFunc := resp.Request.client.RetryAfter + + // Check for custom callback + if retryAfterFunc == nil { + return jitterBackoff(min, max, attempt), nil + } + + result, err := retryAfterFunc(resp.Request.client, resp) + if err != nil { + return 0, err // i.e. 'API quota exceeded' + } + if result == 0 { + return jitterBackoff(min, max, attempt), nil + } + if result < 0 || max < result { + result = max + } + if result < min { + result = min + } + return result, nil +} + +// Return capped exponential backoff with jitter +// http://www.awsarchitectureblog.com/2015/03/backoff.html +func jitterBackoff(min, max time.Duration, attempt int) time.Duration { + base := float64(min) + capLevel := float64(max) + + temp := math.Min(capLevel, base*math.Exp2(float64(attempt))) + ri := time.Duration(temp / 2) + result := randDuration(ri) + + if result < min { + result = min + } + + return result +} + +var rnd = newRnd() +var rndMu sync.Mutex + +func randDuration(center time.Duration) time.Duration { + rndMu.Lock() + defer rndMu.Unlock() + + var ri = int64(center) + var jitter = rnd.Int63n(ri) + return time.Duration(math.Abs(float64(ri + jitter))) +} + +func newRnd() *rand.Rand { + var seed = time.Now().UnixNano() + var src = rand.NewSource(seed) + return rand.New(src) +} diff --git a/vendor/github.com/go-resty/resty/v2/trace.go b/vendor/github.com/go-resty/resty/v2/trace.go new file mode 100644 index 000000000..23cf70335 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/trace.go @@ -0,0 +1,130 @@ +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "context" + "crypto/tls" + "net" + "net/http/httptrace" + "time" +) + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// TraceInfo struct +//_______________________________________________________________________ + +// TraceInfo struct is used provide request trace info such as DNS lookup +// duration, Connection obtain duration, Server processing duration, etc. +// +// Since v2.0.0 +type TraceInfo struct { + // DNSLookup is a duration that transport took to perform + // DNS lookup. + DNSLookup time.Duration + + // ConnTime is a duration that took to obtain a successful connection. + ConnTime time.Duration + + // TCPConnTime is a duration that took to obtain the TCP connection. + TCPConnTime time.Duration + + // TLSHandshake is a duration that TLS handshake took place. + TLSHandshake time.Duration + + // ServerTime is a duration that server took to respond first byte. + ServerTime time.Duration + + // ResponseTime is a duration since first response byte from server to + // request completion. + ResponseTime time.Duration + + // TotalTime is a duration that total request took end-to-end. + TotalTime time.Duration + + // IsConnReused is whether this connection has been previously + // used for another HTTP request. + IsConnReused bool + + // IsConnWasIdle is whether this connection was obtained from an + // idle pool. + IsConnWasIdle bool + + // ConnIdleTime is a duration how long the connection was previously + // idle, if IsConnWasIdle is true. + ConnIdleTime time.Duration + + // RequestAttempt is to represent the request attempt made during a Resty + // request execution flow, including retry count. + RequestAttempt int + + // RemoteAddr returns the remote network address. + RemoteAddr net.Addr +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// ClientTrace struct and its methods +//_______________________________________________________________________ + +// tracer struct maps the `httptrace.ClientTrace` hooks into Fields +// with same naming for easy understanding. Plus additional insights +// Request. +type clientTrace struct { + getConn time.Time + dnsStart time.Time + dnsDone time.Time + connectDone time.Time + tlsHandshakeStart time.Time + tlsHandshakeDone time.Time + gotConn time.Time + gotFirstResponseByte time.Time + endTime time.Time + gotConnInfo httptrace.GotConnInfo +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Trace unexported methods +//_______________________________________________________________________ + +func (t *clientTrace) createContext(ctx context.Context) context.Context { + return httptrace.WithClientTrace( + ctx, + &httptrace.ClientTrace{ + DNSStart: func(_ httptrace.DNSStartInfo) { + t.dnsStart = time.Now() + }, + DNSDone: func(_ httptrace.DNSDoneInfo) { + t.dnsDone = time.Now() + }, + ConnectStart: func(_, _ string) { + if t.dnsDone.IsZero() { + t.dnsDone = time.Now() + } + if t.dnsStart.IsZero() { + t.dnsStart = t.dnsDone + } + }, + ConnectDone: func(net, addr string, err error) { + t.connectDone = time.Now() + }, + GetConn: func(_ string) { + t.getConn = time.Now() + }, + GotConn: func(ci httptrace.GotConnInfo) { + t.gotConn = time.Now() + t.gotConnInfo = ci + }, + GotFirstResponseByte: func() { + t.gotFirstResponseByte = time.Now() + }, + TLSHandshakeStart: func() { + t.tlsHandshakeStart = time.Now() + }, + TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { + t.tlsHandshakeDone = time.Now() + }, + }, + ) +} diff --git a/vendor/github.com/go-resty/resty/v2/transport.go b/vendor/github.com/go-resty/resty/v2/transport.go new file mode 100644 index 000000000..e15b48c55 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/transport.go @@ -0,0 +1,35 @@ +// +build go1.13 + +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "net" + "net/http" + "runtime" + "time" +) + +func createTransport(localAddr net.Addr) *http.Transport { + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + } + if localAddr != nil { + dialer.LocalAddr = localAddr + } + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + MaxIdleConnsPerHost: runtime.GOMAXPROCS(0) + 1, + } +} diff --git a/vendor/github.com/go-resty/resty/v2/transport112.go b/vendor/github.com/go-resty/resty/v2/transport112.go new file mode 100644 index 000000000..fbbbc5911 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/transport112.go @@ -0,0 +1,34 @@ +// +build !go1.13 + +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "net" + "net/http" + "runtime" + "time" +) + +func createTransport(localAddr net.Addr) *http.Transport { + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + } + if localAddr != nil { + dialer.LocalAddr = localAddr + } + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + MaxIdleConnsPerHost: runtime.GOMAXPROCS(0) + 1, + } +} diff --git a/vendor/github.com/go-resty/resty/v2/util.go b/vendor/github.com/go-resty/resty/v2/util.go new file mode 100644 index 000000000..1d563befd --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/util.go @@ -0,0 +1,391 @@ +// Copyright (c) 2015-2021 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "bytes" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "path/filepath" + "reflect" + "runtime" + "sort" + "strings" + "sync" +) + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Logger interface +//_______________________________________________________________________ + +// Logger interface is to abstract the logging from Resty. Gives control to +// the Resty users, choice of the logger. +type Logger interface { + Errorf(format string, v ...interface{}) + Warnf(format string, v ...interface{}) + Debugf(format string, v ...interface{}) +} + +func createLogger() *logger { + l := &logger{l: log.New(os.Stderr, "", log.Ldate|log.Lmicroseconds)} + return l +} + +var _ Logger = (*logger)(nil) + +type logger struct { + l *log.Logger +} + +func (l *logger) Errorf(format string, v ...interface{}) { + l.output("ERROR RESTY "+format, v...) +} + +func (l *logger) Warnf(format string, v ...interface{}) { + l.output("WARN RESTY "+format, v...) +} + +func (l *logger) Debugf(format string, v ...interface{}) { + l.output("DEBUG RESTY "+format, v...) +} + +func (l *logger) output(format string, v ...interface{}) { + if len(v) == 0 { + l.l.Print(format) + return + } + l.l.Printf(format, v...) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Package Helper methods +//_______________________________________________________________________ + +// IsStringEmpty method tells whether given string is empty or not +func IsStringEmpty(str string) bool { + return len(strings.TrimSpace(str)) == 0 +} + +// DetectContentType method is used to figure out `Request.Body` content type for request header +func DetectContentType(body interface{}) string { + contentType := plainTextType + kind := kindOf(body) + switch kind { + case reflect.Struct, reflect.Map: + contentType = jsonContentType + case reflect.String: + contentType = plainTextType + default: + if b, ok := body.([]byte); ok { + contentType = http.DetectContentType(b) + } else if kind == reflect.Slice { + contentType = jsonContentType + } + } + + return contentType +} + +// IsJSONType method is to check JSON content type or not +func IsJSONType(ct string) bool { + return jsonCheck.MatchString(ct) +} + +// IsXMLType method is to check XML content type or not +func IsXMLType(ct string) bool { + return xmlCheck.MatchString(ct) +} + +// Unmarshalc content into object from JSON or XML +func Unmarshalc(c *Client, ct string, b []byte, d interface{}) (err error) { + if IsJSONType(ct) { + err = c.JSONUnmarshal(b, d) + } else if IsXMLType(ct) { + err = c.XMLUnmarshal(b, d) + } + + return +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// RequestLog and ResponseLog type +//_______________________________________________________________________ + +// RequestLog struct is used to collected information from resty request +// instance for debug logging. It sent to request log callback before resty +// actually logs the information. +type RequestLog struct { + Header http.Header + Body string +} + +// ResponseLog struct is used to collected information from resty response +// instance for debug logging. It sent to response log callback before resty +// actually logs the information. +type ResponseLog struct { + Header http.Header + Body string +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Package Unexported methods +//_______________________________________________________________________ + +// way to disable the HTML escape as opt-in +func jsonMarshal(c *Client, r *Request, d interface{}) (*bytes.Buffer, error) { + if !r.jsonEscapeHTML || !c.jsonEscapeHTML { + return noescapeJSONMarshal(d) + } + + data, err := c.JSONMarshal(d) + if err != nil { + return nil, err + } + + buf := acquireBuffer() + _, _ = buf.Write(data) + return buf, nil +} + +func firstNonEmpty(v ...string) string { + for _, s := range v { + if !IsStringEmpty(s) { + return s + } + } + return "" +} + +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} + +func createMultipartHeader(param, fileName, contentType string) textproto.MIMEHeader { + hdr := make(textproto.MIMEHeader) + + var contentDispositionValue string + if IsStringEmpty(fileName) { + contentDispositionValue = fmt.Sprintf(`form-data; name="%s"`, param) + } else { + contentDispositionValue = fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + param, escapeQuotes(fileName)) + } + hdr.Set("Content-Disposition", contentDispositionValue) + + if !IsStringEmpty(contentType) { + hdr.Set(hdrContentTypeKey, contentType) + } + return hdr +} + +func addMultipartFormField(w *multipart.Writer, mf *MultipartField) error { + partWriter, err := w.CreatePart(createMultipartHeader(mf.Param, mf.FileName, mf.ContentType)) + if err != nil { + return err + } + + _, err = io.Copy(partWriter, mf.Reader) + return err +} + +func writeMultipartFormFile(w *multipart.Writer, fieldName, fileName string, r io.Reader) error { + // Auto detect actual multipart content type + cbuf := make([]byte, 512) + size, err := r.Read(cbuf) + if err != nil && err != io.EOF { + return err + } + + partWriter, err := w.CreatePart(createMultipartHeader(fieldName, fileName, http.DetectContentType(cbuf))) + if err != nil { + return err + } + + if _, err = partWriter.Write(cbuf[:size]); err != nil { + return err + } + + _, err = io.Copy(partWriter, r) + return err +} + +func addFile(w *multipart.Writer, fieldName, path string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer closeq(file) + return writeMultipartFormFile(w, fieldName, filepath.Base(path), file) +} + +func addFileReader(w *multipart.Writer, f *File) error { + return writeMultipartFormFile(w, f.ParamName, f.Name, f.Reader) +} + +func getPointer(v interface{}) interface{} { + vv := valueOf(v) + if vv.Kind() == reflect.Ptr { + return v + } + return reflect.New(vv.Type()).Interface() +} + +func isPayloadSupported(m string, allowMethodGet bool) bool { + return !(m == MethodHead || m == MethodOptions || (m == MethodGet && !allowMethodGet)) +} + +func typeOf(i interface{}) reflect.Type { + return indirect(valueOf(i)).Type() +} + +func valueOf(i interface{}) reflect.Value { + return reflect.ValueOf(i) +} + +func indirect(v reflect.Value) reflect.Value { + return reflect.Indirect(v) +} + +func kindOf(v interface{}) reflect.Kind { + return typeOf(v).Kind() +} + +func createDirectory(dir string) (err error) { + if _, err = os.Stat(dir); err != nil { + if os.IsNotExist(err) { + if err = os.MkdirAll(dir, 0755); err != nil { + return + } + } + } + return +} + +func canJSONMarshal(contentType string, kind reflect.Kind) bool { + return IsJSONType(contentType) && (kind == reflect.Struct || kind == reflect.Map || kind == reflect.Slice) +} + +func functionName(i interface{}) string { + return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() +} + +func acquireBuffer() *bytes.Buffer { + return bufPool.Get().(*bytes.Buffer) +} + +func releaseBuffer(buf *bytes.Buffer) { + if buf != nil { + buf.Reset() + bufPool.Put(buf) + } +} + +// requestBodyReleaser wraps requests's body and implements custom Close for it. +// The Close method closes original body and releases request body back to sync.Pool. +type requestBodyReleaser struct { + releaseOnce sync.Once + reqBuf *bytes.Buffer + io.ReadCloser +} + +func newRequestBodyReleaser(respBody io.ReadCloser, reqBuf *bytes.Buffer) io.ReadCloser { + if reqBuf == nil { + return respBody + } + + return &requestBodyReleaser{ + reqBuf: reqBuf, + ReadCloser: respBody, + } +} + +func (rr *requestBodyReleaser) Close() error { + err := rr.ReadCloser.Close() + rr.releaseOnce.Do(func() { + releaseBuffer(rr.reqBuf) + }) + + return err +} + +func closeq(v interface{}) { + if c, ok := v.(io.Closer); ok { + silently(c.Close()) + } +} + +func silently(_ ...interface{}) {} + +func composeHeaders(c *Client, r *Request, hdrs http.Header) string { + str := make([]string, 0, len(hdrs)) + for _, k := range sortHeaderKeys(hdrs) { + var v string + if k == "Cookie" { + cv := strings.TrimSpace(strings.Join(hdrs[k], ", ")) + if c.GetClient().Jar != nil { + for _, c := range c.GetClient().Jar.Cookies(r.RawRequest.URL) { + if cv != "" { + cv = cv + "; " + c.String() + } else { + cv = c.String() + } + } + } + v = strings.TrimSpace(fmt.Sprintf("%25s: %s", k, cv)) + } else { + v = strings.TrimSpace(fmt.Sprintf("%25s: %s", k, strings.Join(hdrs[k], ", "))) + } + if v != "" { + str = append(str, "\t"+v) + } + } + return strings.Join(str, "\n") +} + +func sortHeaderKeys(hdrs http.Header) []string { + keys := make([]string, 0, len(hdrs)) + for key := range hdrs { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func copyHeaders(hdrs http.Header) http.Header { + nh := http.Header{} + for k, v := range hdrs { + nh[k] = v + } + return nh +} + +type noRetryErr struct { + err error +} + +func (e *noRetryErr) Error() string { + return e.err.Error() +} + +func wrapNoRetryErr(err error) error { + if err != nil { + err = &noRetryErr{err: err} + } + return err +} + +func unwrapNoRetryErr(err error) error { + if e, ok := err.(*noRetryErr); ok { + err = e.err + } + return err +} diff --git a/vendor/golang.org/x/net/publicsuffix/list.go b/vendor/golang.org/x/net/publicsuffix/list.go new file mode 100644 index 000000000..200617ea8 --- /dev/null +++ b/vendor/golang.org/x/net/publicsuffix/list.go @@ -0,0 +1,181 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:generate go run gen.go + +// Package publicsuffix provides a public suffix list based on data from +// https://publicsuffix.org/ +// +// A public suffix is one under which Internet users can directly register +// names. It is related to, but different from, a TLD (top level domain). +// +// "com" is a TLD (top level domain). Top level means it has no dots. +// +// "com" is also a public suffix. Amazon and Google have registered different +// siblings under that domain: "amazon.com" and "google.com". +// +// "au" is another TLD, again because it has no dots. But it's not "amazon.au". +// Instead, it's "amazon.com.au". +// +// "com.au" isn't an actual TLD, because it's not at the top level (it has +// dots). But it is an eTLD (effective TLD), because that's the branching point +// for domain name registrars. +// +// Another name for "an eTLD" is "a public suffix". Often, what's more of +// interest is the eTLD+1, or one more label than the public suffix. For +// example, browsers partition read/write access to HTTP cookies according to +// the eTLD+1. Web pages served from "amazon.com.au" can't read cookies from +// "google.com.au", but web pages served from "maps.google.com" can share +// cookies from "www.google.com", so you don't have to sign into Google Maps +// separately from signing into Google Web Search. Note that all four of those +// domains have 3 labels and 2 dots. The first two domains are each an eTLD+1, +// the last two are not (but share the same eTLD+1: "google.com"). +// +// All of these domains have the same eTLD+1: +// - "www.books.amazon.co.uk" +// - "books.amazon.co.uk" +// - "amazon.co.uk" +// Specifically, the eTLD+1 is "amazon.co.uk", because the eTLD is "co.uk". +// +// There is no closed form algorithm to calculate the eTLD of a domain. +// Instead, the calculation is data driven. This package provides a +// pre-compiled snapshot of Mozilla's PSL (Public Suffix List) data at +// https://publicsuffix.org/ +package publicsuffix // import "golang.org/x/net/publicsuffix" + +// TODO: specify case sensitivity and leading/trailing dot behavior for +// func PublicSuffix and func EffectiveTLDPlusOne. + +import ( + "fmt" + "net/http/cookiejar" + "strings" +) + +// List implements the cookiejar.PublicSuffixList interface by calling the +// PublicSuffix function. +var List cookiejar.PublicSuffixList = list{} + +type list struct{} + +func (list) PublicSuffix(domain string) string { + ps, _ := PublicSuffix(domain) + return ps +} + +func (list) String() string { + return version +} + +// PublicSuffix returns the public suffix of the domain using a copy of the +// publicsuffix.org database compiled into the library. +// +// icann is whether the public suffix is managed by the Internet Corporation +// for Assigned Names and Numbers. If not, the public suffix is either a +// privately managed domain (and in practice, not a top level domain) or an +// unmanaged top level domain (and not explicitly mentioned in the +// publicsuffix.org list). For example, "foo.org" and "foo.co.uk" are ICANN +// domains, "foo.dyndns.org" and "foo.blogspot.co.uk" are private domains and +// "cromulent" is an unmanaged top level domain. +// +// Use cases for distinguishing ICANN domains like "foo.com" from private +// domains like "foo.appspot.com" can be found at +// https://wiki.mozilla.org/Public_Suffix_List/Use_Cases +func PublicSuffix(domain string) (publicSuffix string, icann bool) { + lo, hi := uint32(0), uint32(numTLD) + s, suffix, icannNode, wildcard := domain, len(domain), false, false +loop: + for { + dot := strings.LastIndex(s, ".") + if wildcard { + icann = icannNode + suffix = 1 + dot + } + if lo == hi { + break + } + f := find(s[1+dot:], lo, hi) + if f == notFound { + break + } + + u := nodes[f] >> (nodesBitsTextOffset + nodesBitsTextLength) + icannNode = u&(1<>= nodesBitsICANN + u = children[u&(1<>= childrenBitsLo + hi = u & (1<>= childrenBitsHi + switch u & (1<>= childrenBitsNodeType + wildcard = u&(1<>= nodesBitsTextLength + offset := x & (1<