From 525dd239164624bcdc38c9340f46e5ba71cfa931 Mon Sep 17 00:00:00 2001 From: nghialv Date: Tue, 11 Dec 2018 15:37:16 +0900 Subject: [PATCH] feat: bump v0.1.0 --- .bazelrc | 25 + .gitignore | 27 + BUILD.bazel | 11 + Gopkg.lock | 714 ++++++++ Gopkg.toml | 57 + LICENSE | 20 + Makefile | 45 + NOTICE.md | 10 + README.md | 161 ++ WORKSPACE | 394 +++++ cmd/BUILD.bazel | 15 + cmd/example/BUILD.bazel | 30 + cmd/example/main.go | 29 + cmd/image.bzl | 12 + cmd/lotus/BUILD.bazel | 27 + cmd/lotus/main.go | 23 + docs/README.md | 7 + docs/configurations.md | 84 + docs/development.md | 55 + docs/fqa.md | 13 + docs/life-of-lotus-crd.md | 3 + docs/life-of-lotus-crd.png | Bin 0 -> 264001 bytes docs/lotus-crd-configurations.md | 57 + examples/README.md | 39 + examples/helloworld/deployment.yaml | 26 + examples/helloworld/service.yaml | 14 + examples/simple-grpc-scenario.yaml | 18 + examples/simple-http-scenario.yaml | 17 + examples/three-steps-scenario.yaml | 45 + examples/virtualuser-scenario.yaml | 25 + hack/boilerplate.go.txt | 5 + hack/generate-dashboards.sh | 20 + hack/generate-manifests.sh | 35 + hack/print-workspace-status.sh | 37 + hack/update-codegen.sh | 18 + install/README.md | 36 + install/dashboard-templates/common.jsonnet | 72 + .../grpc-dashboard.jsonnet | 21 + .../http-dashboard.jsonnet | 20 + install/dashboard-templates/panels.jsonnet | 124 ++ install/helm/.helmignore | 21 + install/helm/Chart.yaml | 5 + install/helm/charts/grafana-1.19.0.tgz | Bin 0 -> 10659 bytes install/helm/dashboards/grpc-dashboard.json | 756 +++++++++ install/helm/dashboards/http-dashboard.json | 671 ++++++++ install/helm/requirements.lock | 6 + install/helm/requirements.yaml | 5 + install/helm/templates/NOTES.txt | 0 install/helm/templates/_helpers.tpl | 32 + .../controller-config-configmap.yaml | 7 + .../helm/templates/controller-deployment.yaml | 39 + install/helm/templates/controller-rbac.yaml | 65 + install/helm/templates/crd.yaml | 44 + .../grafana-dashboards-configmap.yaml | 9 + .../grafana-datasources-configmap.yaml | 17 + install/helm/templates/prometheus-rbac.yaml | 37 + install/helm/values.yaml | 34 + install/manifest-generate-values-norbac.yaml | 18 + install/manifest-generate-values.yaml | 10 + .../controller-config-configmap.yaml | 21 + .../controller-deployment.yaml | 35 + install/manifests-norbac/crd.yaml | 46 + .../grafana-configmap-dashboard-provider.yaml | 23 + .../manifests-norbac/grafana-configmap.yaml | 24 + .../grafana-dashboards-configmap.yaml | 1439 +++++++++++++++++ .../grafana-datasources-configmap.yaml | 18 + .../manifests-norbac/grafana-deployment.yaml | 132 ++ .../grafana-podsecuritypolicy.yaml | 41 + install/manifests-norbac/grafana-secret.yaml | 16 + install/manifests-norbac/grafana-service.yaml | 22 + .../controller-config-configmap.yaml | 21 + install/manifests/controller-deployment.yaml | 37 + install/manifests/controller-rbac.yaml | 66 + install/manifests/crd.yaml | 46 + install/manifests/grafana-clusterrole.yaml | 16 + .../manifests/grafana-clusterrolebinding.yaml | 20 + .../grafana-configmap-dashboard-provider.yaml | 23 + install/manifests/grafana-configmap.yaml | 24 + .../grafana-dashboards-configmap.yaml | 1439 +++++++++++++++++ .../grafana-datasources-configmap.yaml | 18 + install/manifests/grafana-deployment.yaml | 132 ++ .../manifests/grafana-podsecuritypolicy.yaml | 41 + install/manifests/grafana-role.yaml | 17 + install/manifests/grafana-rolebinding.yaml | 18 + install/manifests/grafana-secret.yaml | 16 + install/manifests/grafana-service.yaml | 22 + install/manifests/grafana-serviceaccount.yaml | 12 + install/manifests/prometheus-rbac.yaml | 38 + jsonnetfile.json | 14 + jsonnetfile.lock.json | 14 + libsonnet/grafonnet/alert_condition.libsonnet | 44 + libsonnet/grafonnet/annotation.libsonnet | 35 + libsonnet/grafonnet/cloudwatch.libsonnet | 39 + libsonnet/grafonnet/dashboard.libsonnet | 121 ++ libsonnet/grafonnet/elasticsearch.libsonnet | 38 + libsonnet/grafonnet/grafana.libsonnet | 19 + libsonnet/grafonnet/graph_panel.libsonnet | 232 +++ libsonnet/grafonnet/graphite.libsonnet | 27 + libsonnet/grafonnet/influxdb.libsonnet | 27 + libsonnet/grafonnet/link.libsonnet | 24 + libsonnet/grafonnet/prometheus.libsonnet | 19 + libsonnet/grafonnet/row.libsonnet | 32 + libsonnet/grafonnet/singlestat.libsonnet | 127 ++ libsonnet/grafonnet/sql.libsonnet | 11 + libsonnet/grafonnet/table_panel.libsonnet | 41 + libsonnet/grafonnet/template.libsonnet | 131 ++ libsonnet/grafonnet/text.libsonnet | 17 + libsonnet/grafonnet/timepicker.libsonnet | 30 + pgv_proto_library.bzl | 24 + pkg/app/example/cmd/helloworld/BUILD.bazel | 17 + pkg/app/example/cmd/helloworld/helloworld.go | 122 ++ pkg/app/example/cmd/simplegrpc/BUILD.bazel | 17 + pkg/app/example/cmd/simplegrpc/scenario.go | 68 + pkg/app/example/cmd/simplehttp/BUILD.bazel | 16 + pkg/app/example/cmd/simplehttp/scenario.go | 57 + pkg/app/example/cmd/threesteps/BUILD.bazel | 19 + pkg/app/example/cmd/threesteps/scenario.go | 162 ++ pkg/app/example/cmd/virtualuser/BUILD.bazel | 23 + pkg/app/example/cmd/virtualuser/scenario.go | 65 + pkg/app/example/cmd/virtualuser/user.go | 65 + pkg/app/example/helloworld/BUILD.bazel | 23 + pkg/app/example/helloworld/helloworld.proto | 26 + pkg/app/lotus/apis/lotus/BUILD.bazel | 8 + pkg/app/lotus/apis/lotus/register.go | 5 + pkg/app/lotus/apis/lotus/v1beta1/BUILD.bazel | 20 + pkg/app/lotus/apis/lotus/v1beta1/doc.go | 5 + pkg/app/lotus/apis/lotus/v1beta1/register.go | 37 + pkg/app/lotus/apis/lotus/v1beta1/types.go | 85 + .../lotus/v1beta1/zz_generated.deepcopy.go | 284 ++++ .../client/clientset/versioned/BUILD.bazel | 17 + .../client/clientset/versioned/clientset.go | 88 + .../lotus/client/clientset/versioned/doc.go | 10 + .../clientset/versioned/fake/BUILD.bazel | 26 + .../versioned/fake/clientset_generated.go | 72 + .../client/clientset/versioned/fake/doc.go | 10 + .../clientset/versioned/fake/register.go | 44 + .../clientset/versioned/scheme/BUILD.bazel | 18 + .../client/clientset/versioned/scheme/doc.go | 10 + .../clientset/versioned/scheme/register.go | 44 + .../versioned/typed/lotus/v1beta1/BUILD.bazel | 22 + .../versioned/typed/lotus/v1beta1/doc.go | 10 + .../typed/lotus/v1beta1/fake/BUILD.bazel | 23 + .../versioned/typed/lotus/v1beta1/fake/doc.go | 10 + .../typed/lotus/v1beta1/fake/fake_lotus.go | 130 ++ .../lotus/v1beta1/fake/fake_lotus_client.go | 30 + .../lotus/v1beta1/generated_expansion.go | 11 + .../versioned/typed/lotus/v1beta1/lotus.go | 164 ++ .../typed/lotus/v1beta1/lotus_client.go | 80 + .../informers/externalversions/BUILD.bazel | 21 + .../informers/externalversions/factory.go | 170 ++ .../informers/externalversions/generic.go | 52 + .../internalinterfaces/BUILD.bazel | 14 + .../internalinterfaces/factory_interfaces.go | 28 + .../externalversions/lotus/BUILD.bazel | 12 + .../externalversions/lotus/interface.go | 36 + .../lotus/v1beta1/BUILD.bazel | 21 + .../lotus/v1beta1/interface.go | 35 + .../externalversions/lotus/v1beta1/lotus.go | 79 + .../client/listers/lotus/v1beta1/BUILD.bazel | 17 + .../lotus/v1beta1/expansion_generated.go | 17 + .../client/listers/lotus/v1beta1/lotus.go | 84 + pkg/app/lotus/cmd/controller/BUILD.bazel | 20 + pkg/app/lotus/cmd/controller/controller.go | 102 ++ pkg/app/lotus/cmd/monitor/BUILD.bazel | 19 + pkg/app/lotus/cmd/monitor/monitor.go | 239 +++ pkg/app/lotus/config/BUILD.bazel | 43 + pkg/app/lotus/config/config.go | 97 ++ pkg/app/lotus/config/config.proto | 99 ++ pkg/app/lotus/config/config_test.go | 97 ++ pkg/app/lotus/config/testdata/valid.yaml | 26 + pkg/app/lotus/controller/BUILD.bazel | 41 + pkg/app/lotus/controller/controller.go | 466 ++++++ pkg/app/lotus/controller/controller_test.go | 1 + pkg/app/lotus/datasource/BUILD.bazel | 13 + pkg/app/lotus/datasource/datasource.go | 56 + .../lotus/datasource/prometheus/BUILD.bazel | 30 + .../lotus/datasource/prometheus/builder.go | 35 + .../lotus/datasource/prometheus/prometheus.go | 288 ++++ .../datasource/prometheus/prometheus_test.go | 11 + pkg/app/lotus/datasource/prometheus/query.go | 70 + pkg/app/lotus/datasource/registry/BUILD.bazel | 13 + pkg/app/lotus/datasource/registry/registry.go | 43 + pkg/app/lotus/kubeclient/BUILD.bazel | 24 + pkg/app/lotus/kubeclient/kubeclient.go | 158 ++ pkg/app/lotus/kubeclient/kubeclient_test.go | 1 + pkg/app/lotus/model/BUILD.bazel | 29 + pkg/app/lotus/model/lotus.go | 18 + pkg/app/lotus/model/metrics_summary.go | 36 + pkg/app/lotus/model/render.go | 184 +++ pkg/app/lotus/model/render_test.go | 99 ++ pkg/app/lotus/model/result.go | 53 + pkg/app/lotus/model/templates.go | 48 + pkg/app/lotus/reporter/BUILD.bazel | 25 + pkg/app/lotus/reporter/azure/BUILD.bazel | 8 + pkg/app/lotus/reporter/azure/azure.go | 3 + pkg/app/lotus/reporter/gcs/BUILD.bazel | 16 + pkg/app/lotus/reporter/gcs/gcs.go | 95 ++ pkg/app/lotus/reporter/logger/BUILD.bazel | 14 + pkg/app/lotus/reporter/logger/logger.go | 40 + pkg/app/lotus/reporter/registry/BUILD.bazel | 15 + pkg/app/lotus/reporter/registry/registry.go | 47 + pkg/app/lotus/reporter/reporter.go | 65 + pkg/app/lotus/reporter/reporter_test.go | 66 + pkg/app/lotus/reporter/s3/BUILD.bazel | 8 + pkg/app/lotus/reporter/s3/s3.go | 3 + pkg/app/lotus/reporter/slack/BUILD.bazel | 14 + pkg/app/lotus/reporter/slack/slack.go | 101 ++ pkg/app/lotus/resource/BUILD.bazel | 37 + pkg/app/lotus/resource/factory.go | 159 ++ pkg/app/lotus/resource/job.go | 148 ++ pkg/app/lotus/resource/prometheus.go | 213 +++ pkg/app/lotus/resource/secret.go | 59 + pkg/app/lotus/resource/static_factory.go | 82 + pkg/app/lotus/resource/templates.go | 123 ++ pkg/app/lotus/resource/templates_test.go | 22 + pkg/app/lotus/resource/thanos.go | 242 +++ pkg/app/lotus/resource/worker.go | 72 + pkg/cli/BUILD.bazel | 17 + pkg/cli/app.go | 52 + pkg/cli/cmd.go | 64 + pkg/log/BUILD.bazel | 20 + pkg/log/log.go | 108 ++ pkg/log/log_test.go | 59 + pkg/metrics/BUILD.bazel | 25 + pkg/metrics/grpcmetrics/BUILD.bazel | 22 + pkg/metrics/grpcmetrics/common.go | 163 ++ pkg/metrics/grpcmetrics/grpc.go | 65 + pkg/metrics/grpcmetrics/grpc_stats.go | 123 ++ pkg/metrics/httpmetrics/BUILD.bazel | 16 + pkg/metrics/httpmetrics/http.go | 138 ++ pkg/metrics/httpmetrics/http_stats.go | 101 ++ pkg/metrics/logger.go | 18 + pkg/metrics/metrics.go | 156 ++ pkg/metrics/metrics_test.go | 1 + pkg/version/BUILD.bazel | 10 + pkg/version/def.bzl | 35 + pkg/version/version.go | 36 + pkg/virtualuser/BUILD.bazel | 20 + pkg/virtualuser/virtualuser.go | 121 ++ pkg/virtualuser/virtualuser_test.go | 1 + 240 files changed, 17092 insertions(+) create mode 100644 .bazelrc create mode 100644 .gitignore create mode 100644 BUILD.bazel create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE.md create mode 100644 README.md create mode 100644 WORKSPACE create mode 100644 cmd/BUILD.bazel create mode 100644 cmd/example/BUILD.bazel create mode 100644 cmd/example/main.go create mode 100644 cmd/image.bzl create mode 100644 cmd/lotus/BUILD.bazel create mode 100644 cmd/lotus/main.go create mode 100644 docs/README.md create mode 100644 docs/configurations.md create mode 100644 docs/development.md create mode 100644 docs/fqa.md create mode 100644 docs/life-of-lotus-crd.md create mode 100644 docs/life-of-lotus-crd.png create mode 100644 docs/lotus-crd-configurations.md create mode 100644 examples/README.md create mode 100644 examples/helloworld/deployment.yaml create mode 100644 examples/helloworld/service.yaml create mode 100644 examples/simple-grpc-scenario.yaml create mode 100644 examples/simple-http-scenario.yaml create mode 100644 examples/three-steps-scenario.yaml create mode 100644 examples/virtualuser-scenario.yaml create mode 100644 hack/boilerplate.go.txt create mode 100755 hack/generate-dashboards.sh create mode 100755 hack/generate-manifests.sh create mode 100755 hack/print-workspace-status.sh create mode 100755 hack/update-codegen.sh create mode 100644 install/README.md create mode 100644 install/dashboard-templates/common.jsonnet create mode 100644 install/dashboard-templates/grpc-dashboard.jsonnet create mode 100644 install/dashboard-templates/http-dashboard.jsonnet create mode 100644 install/dashboard-templates/panels.jsonnet create mode 100644 install/helm/.helmignore create mode 100644 install/helm/Chart.yaml create mode 100644 install/helm/charts/grafana-1.19.0.tgz create mode 100644 install/helm/dashboards/grpc-dashboard.json create mode 100644 install/helm/dashboards/http-dashboard.json create mode 100644 install/helm/requirements.lock create mode 100644 install/helm/requirements.yaml create mode 100644 install/helm/templates/NOTES.txt create mode 100644 install/helm/templates/_helpers.tpl create mode 100644 install/helm/templates/controller-config-configmap.yaml create mode 100644 install/helm/templates/controller-deployment.yaml create mode 100644 install/helm/templates/controller-rbac.yaml create mode 100644 install/helm/templates/crd.yaml create mode 100644 install/helm/templates/grafana-dashboards-configmap.yaml create mode 100644 install/helm/templates/grafana-datasources-configmap.yaml create mode 100644 install/helm/templates/prometheus-rbac.yaml create mode 100644 install/helm/values.yaml create mode 100644 install/manifest-generate-values-norbac.yaml create mode 100644 install/manifest-generate-values.yaml create mode 100644 install/manifests-norbac/controller-config-configmap.yaml create mode 100644 install/manifests-norbac/controller-deployment.yaml create mode 100644 install/manifests-norbac/crd.yaml create mode 100644 install/manifests-norbac/grafana-configmap-dashboard-provider.yaml create mode 100644 install/manifests-norbac/grafana-configmap.yaml create mode 100644 install/manifests-norbac/grafana-dashboards-configmap.yaml create mode 100644 install/manifests-norbac/grafana-datasources-configmap.yaml create mode 100644 install/manifests-norbac/grafana-deployment.yaml create mode 100644 install/manifests-norbac/grafana-podsecuritypolicy.yaml create mode 100644 install/manifests-norbac/grafana-secret.yaml create mode 100644 install/manifests-norbac/grafana-service.yaml create mode 100644 install/manifests/controller-config-configmap.yaml create mode 100644 install/manifests/controller-deployment.yaml create mode 100644 install/manifests/controller-rbac.yaml create mode 100644 install/manifests/crd.yaml create mode 100644 install/manifests/grafana-clusterrole.yaml create mode 100644 install/manifests/grafana-clusterrolebinding.yaml create mode 100644 install/manifests/grafana-configmap-dashboard-provider.yaml create mode 100644 install/manifests/grafana-configmap.yaml create mode 100644 install/manifests/grafana-dashboards-configmap.yaml create mode 100644 install/manifests/grafana-datasources-configmap.yaml create mode 100644 install/manifests/grafana-deployment.yaml create mode 100644 install/manifests/grafana-podsecuritypolicy.yaml create mode 100644 install/manifests/grafana-role.yaml create mode 100644 install/manifests/grafana-rolebinding.yaml create mode 100644 install/manifests/grafana-secret.yaml create mode 100644 install/manifests/grafana-service.yaml create mode 100644 install/manifests/grafana-serviceaccount.yaml create mode 100644 install/manifests/prometheus-rbac.yaml create mode 100644 jsonnetfile.json create mode 100644 jsonnetfile.lock.json create mode 100644 libsonnet/grafonnet/alert_condition.libsonnet create mode 100644 libsonnet/grafonnet/annotation.libsonnet create mode 100644 libsonnet/grafonnet/cloudwatch.libsonnet create mode 100644 libsonnet/grafonnet/dashboard.libsonnet create mode 100644 libsonnet/grafonnet/elasticsearch.libsonnet create mode 100644 libsonnet/grafonnet/grafana.libsonnet create mode 100644 libsonnet/grafonnet/graph_panel.libsonnet create mode 100644 libsonnet/grafonnet/graphite.libsonnet create mode 100644 libsonnet/grafonnet/influxdb.libsonnet create mode 100644 libsonnet/grafonnet/link.libsonnet create mode 100644 libsonnet/grafonnet/prometheus.libsonnet create mode 100644 libsonnet/grafonnet/row.libsonnet create mode 100644 libsonnet/grafonnet/singlestat.libsonnet create mode 100644 libsonnet/grafonnet/sql.libsonnet create mode 100644 libsonnet/grafonnet/table_panel.libsonnet create mode 100644 libsonnet/grafonnet/template.libsonnet create mode 100644 libsonnet/grafonnet/text.libsonnet create mode 100644 libsonnet/grafonnet/timepicker.libsonnet create mode 100644 pgv_proto_library.bzl create mode 100644 pkg/app/example/cmd/helloworld/BUILD.bazel create mode 100644 pkg/app/example/cmd/helloworld/helloworld.go create mode 100644 pkg/app/example/cmd/simplegrpc/BUILD.bazel create mode 100644 pkg/app/example/cmd/simplegrpc/scenario.go create mode 100644 pkg/app/example/cmd/simplehttp/BUILD.bazel create mode 100644 pkg/app/example/cmd/simplehttp/scenario.go create mode 100644 pkg/app/example/cmd/threesteps/BUILD.bazel create mode 100644 pkg/app/example/cmd/threesteps/scenario.go create mode 100644 pkg/app/example/cmd/virtualuser/BUILD.bazel create mode 100644 pkg/app/example/cmd/virtualuser/scenario.go create mode 100644 pkg/app/example/cmd/virtualuser/user.go create mode 100644 pkg/app/example/helloworld/BUILD.bazel create mode 100644 pkg/app/example/helloworld/helloworld.proto create mode 100644 pkg/app/lotus/apis/lotus/BUILD.bazel create mode 100644 pkg/app/lotus/apis/lotus/register.go create mode 100644 pkg/app/lotus/apis/lotus/v1beta1/BUILD.bazel create mode 100644 pkg/app/lotus/apis/lotus/v1beta1/doc.go create mode 100644 pkg/app/lotus/apis/lotus/v1beta1/register.go create mode 100644 pkg/app/lotus/apis/lotus/v1beta1/types.go create mode 100644 pkg/app/lotus/apis/lotus/v1beta1/zz_generated.deepcopy.go create mode 100644 pkg/app/lotus/client/clientset/versioned/BUILD.bazel create mode 100644 pkg/app/lotus/client/clientset/versioned/clientset.go create mode 100644 pkg/app/lotus/client/clientset/versioned/doc.go create mode 100644 pkg/app/lotus/client/clientset/versioned/fake/BUILD.bazel create mode 100644 pkg/app/lotus/client/clientset/versioned/fake/clientset_generated.go create mode 100644 pkg/app/lotus/client/clientset/versioned/fake/doc.go create mode 100644 pkg/app/lotus/client/clientset/versioned/fake/register.go create mode 100644 pkg/app/lotus/client/clientset/versioned/scheme/BUILD.bazel create mode 100644 pkg/app/lotus/client/clientset/versioned/scheme/doc.go create mode 100644 pkg/app/lotus/client/clientset/versioned/scheme/register.go create mode 100644 pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/BUILD.bazel create mode 100644 pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/doc.go create mode 100644 pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/BUILD.bazel create mode 100644 pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/doc.go create mode 100644 pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/fake_lotus.go create mode 100644 pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/fake_lotus_client.go create mode 100644 pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/generated_expansion.go create mode 100644 pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/lotus.go create mode 100644 pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/lotus_client.go create mode 100644 pkg/app/lotus/client/informers/externalversions/BUILD.bazel create mode 100644 pkg/app/lotus/client/informers/externalversions/factory.go create mode 100644 pkg/app/lotus/client/informers/externalversions/generic.go create mode 100644 pkg/app/lotus/client/informers/externalversions/internalinterfaces/BUILD.bazel create mode 100644 pkg/app/lotus/client/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 pkg/app/lotus/client/informers/externalversions/lotus/BUILD.bazel create mode 100644 pkg/app/lotus/client/informers/externalversions/lotus/interface.go create mode 100644 pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/BUILD.bazel create mode 100644 pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/interface.go create mode 100644 pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/lotus.go create mode 100644 pkg/app/lotus/client/listers/lotus/v1beta1/BUILD.bazel create mode 100644 pkg/app/lotus/client/listers/lotus/v1beta1/expansion_generated.go create mode 100644 pkg/app/lotus/client/listers/lotus/v1beta1/lotus.go create mode 100644 pkg/app/lotus/cmd/controller/BUILD.bazel create mode 100644 pkg/app/lotus/cmd/controller/controller.go create mode 100644 pkg/app/lotus/cmd/monitor/BUILD.bazel create mode 100644 pkg/app/lotus/cmd/monitor/monitor.go create mode 100644 pkg/app/lotus/config/BUILD.bazel create mode 100644 pkg/app/lotus/config/config.go create mode 100644 pkg/app/lotus/config/config.proto create mode 100644 pkg/app/lotus/config/config_test.go create mode 100644 pkg/app/lotus/config/testdata/valid.yaml create mode 100644 pkg/app/lotus/controller/BUILD.bazel create mode 100644 pkg/app/lotus/controller/controller.go create mode 100644 pkg/app/lotus/controller/controller_test.go create mode 100644 pkg/app/lotus/datasource/BUILD.bazel create mode 100644 pkg/app/lotus/datasource/datasource.go create mode 100644 pkg/app/lotus/datasource/prometheus/BUILD.bazel create mode 100644 pkg/app/lotus/datasource/prometheus/builder.go create mode 100644 pkg/app/lotus/datasource/prometheus/prometheus.go create mode 100644 pkg/app/lotus/datasource/prometheus/prometheus_test.go create mode 100644 pkg/app/lotus/datasource/prometheus/query.go create mode 100644 pkg/app/lotus/datasource/registry/BUILD.bazel create mode 100644 pkg/app/lotus/datasource/registry/registry.go create mode 100644 pkg/app/lotus/kubeclient/BUILD.bazel create mode 100644 pkg/app/lotus/kubeclient/kubeclient.go create mode 100644 pkg/app/lotus/kubeclient/kubeclient_test.go create mode 100644 pkg/app/lotus/model/BUILD.bazel create mode 100644 pkg/app/lotus/model/lotus.go create mode 100644 pkg/app/lotus/model/metrics_summary.go create mode 100644 pkg/app/lotus/model/render.go create mode 100644 pkg/app/lotus/model/render_test.go create mode 100644 pkg/app/lotus/model/result.go create mode 100644 pkg/app/lotus/model/templates.go create mode 100644 pkg/app/lotus/reporter/BUILD.bazel create mode 100644 pkg/app/lotus/reporter/azure/BUILD.bazel create mode 100644 pkg/app/lotus/reporter/azure/azure.go create mode 100644 pkg/app/lotus/reporter/gcs/BUILD.bazel create mode 100644 pkg/app/lotus/reporter/gcs/gcs.go create mode 100644 pkg/app/lotus/reporter/logger/BUILD.bazel create mode 100644 pkg/app/lotus/reporter/logger/logger.go create mode 100644 pkg/app/lotus/reporter/registry/BUILD.bazel create mode 100644 pkg/app/lotus/reporter/registry/registry.go create mode 100644 pkg/app/lotus/reporter/reporter.go create mode 100644 pkg/app/lotus/reporter/reporter_test.go create mode 100644 pkg/app/lotus/reporter/s3/BUILD.bazel create mode 100644 pkg/app/lotus/reporter/s3/s3.go create mode 100644 pkg/app/lotus/reporter/slack/BUILD.bazel create mode 100644 pkg/app/lotus/reporter/slack/slack.go create mode 100644 pkg/app/lotus/resource/BUILD.bazel create mode 100644 pkg/app/lotus/resource/factory.go create mode 100644 pkg/app/lotus/resource/job.go create mode 100644 pkg/app/lotus/resource/prometheus.go create mode 100644 pkg/app/lotus/resource/secret.go create mode 100644 pkg/app/lotus/resource/static_factory.go create mode 100644 pkg/app/lotus/resource/templates.go create mode 100644 pkg/app/lotus/resource/templates_test.go create mode 100644 pkg/app/lotus/resource/thanos.go create mode 100644 pkg/app/lotus/resource/worker.go create mode 100644 pkg/cli/BUILD.bazel create mode 100644 pkg/cli/app.go create mode 100644 pkg/cli/cmd.go create mode 100644 pkg/log/BUILD.bazel create mode 100644 pkg/log/log.go create mode 100644 pkg/log/log_test.go create mode 100644 pkg/metrics/BUILD.bazel create mode 100644 pkg/metrics/grpcmetrics/BUILD.bazel create mode 100644 pkg/metrics/grpcmetrics/common.go create mode 100644 pkg/metrics/grpcmetrics/grpc.go create mode 100644 pkg/metrics/grpcmetrics/grpc_stats.go create mode 100644 pkg/metrics/httpmetrics/BUILD.bazel create mode 100644 pkg/metrics/httpmetrics/http.go create mode 100644 pkg/metrics/httpmetrics/http_stats.go create mode 100644 pkg/metrics/logger.go create mode 100644 pkg/metrics/metrics.go create mode 100644 pkg/metrics/metrics_test.go create mode 100644 pkg/version/BUILD.bazel create mode 100644 pkg/version/def.bzl create mode 100644 pkg/version/version.go create mode 100644 pkg/virtualuser/BUILD.bazel create mode 100644 pkg/virtualuser/virtualuser.go create mode 100644 pkg/virtualuser/virtualuser_test.go diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 0000000..e16f644 --- /dev/null +++ b/.bazelrc @@ -0,0 +1,25 @@ +startup --expand_configs_in_place + +# Show us more details +build --show_timestamps --verbose_failures +test --test_output=errors --test_verbose_timeout_warnings + +# Include git version info +build --workspace_status_command hack/print-workspace-status.sh + +# Preset definitions +build --define DOCKER_REGISTRY=index.docker.io/nghialv2607 + +# https://github.com/bazelbuild/rules_go/blob/master/go/modes.rst +build --features=pure + +# Make /tmp hermetic +build --sandbox_tmpfs_path=/tmp + +# Ensure that Bazel never runs as root, which can cause unit tests to fail. +# This flag requires Bazel 0.5.0+ +build --sandbox_fake_username + +# Enable go race detection +build:unit --features=race +test:unit --features=race diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8b40d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +.DS_Store + +# Bazel +/bazel-bin +/bazel-genfiles +/bazel-lotus +/bazel-out +/bazel-testlogs + +# golang +/vendor + +# libsonnet +/libsonnet/.tmp diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 0000000..d0b5ad4 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,11 @@ +load("@bazel_gazelle//:def.bzl", "gazelle") + +# gazelle:exclude vendor +# gazelle:exclude install +# gazelle:exclude hack +# gazelle:build_file_name BUILD.bazel +# gazelle:prefix github.com/nghialv/lotus + +gazelle( + name = "gazelle", +) diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..4d02b8e --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,714 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "cloud.google.com/go" + packages = [ + "compute/metadata", + "iam", + "internal", + "internal/optional", + "internal/trace", + "internal/version", + "storage" + ] + revision = "debcad1964693daf8ef4bc06292d7e828e075130" + version = "v0.31.0" + +[[projects]] + branch = "master" + name = "github.com/beorn7/perks" + packages = ["quantile"] + revision = "3a771d992973f24aa725d07868b467d1ddfceafb" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73" + version = "v1.1.1" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = [ + "proto", + "sortkeys" + ] + revision = "636bf0302bc95575d69441b25a2603156ffdddf1" + version = "v1.1.1" + +[[projects]] + branch = "master" + name = "github.com/golang/glog" + packages = ["."] + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + +[[projects]] + branch = "master" + name = "github.com/golang/groupcache" + packages = ["lru"] + revision = "c65c006176ff7ff98bb916961c7abbc6b0afc0aa" + +[[projects]] + name = "github.com/golang/protobuf" + packages = [ + "jsonpb", + "proto", + "protoc-gen-go/descriptor", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/struct", + "ptypes/timestamp" + ] + revision = "aa810b61a9c79d51363740d207bb46cf8e620ed5" + version = "v1.2.0" + +[[projects]] + branch = "master" + name = "github.com/google/btree" + packages = ["."] + revision = "4030bb1f1f0c35b30ca7009e9ebd06849dd45306" + +[[projects]] + branch = "master" + name = "github.com/google/gofuzz" + packages = ["."] + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + +[[projects]] + name = "github.com/googleapis/gax-go" + packages = ["."] + revision = "b001040cd31805261cbd978842099e326dfa857b" + version = "v2.0.2" + +[[projects]] + name = "github.com/googleapis/gnostic" + packages = [ + "OpenAPIv2", + "compiler", + "extensions" + ] + revision = "7c663266750e7d82587642f65e60bc4083f1f84e" + version = "v0.2.0" + +[[projects]] + branch = "master" + name = "github.com/gregjones/httpcache" + packages = [ + ".", + "diskcache" + ] + revision = "9cad4c3443a7200dd6400aef47183728de563a38" + +[[projects]] + name = "github.com/hashicorp/golang-lru" + packages = [ + ".", + "simplelru" + ] + revision = "20f1fb78b0740ba8c3cb143a61e86ba5c8669768" + version = "v0.5.0" + +[[projects]] + name = "github.com/imdario/mergo" + packages = ["."] + revision = "9f23e2d6bd2a77f959b2bf6acdbefd708a83a4a4" + version = "v0.3.6" + +[[projects]] + name = "github.com/inconshreveable/mousetrap" + packages = ["."] + revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" + version = "v1.0" + +[[projects]] + name = "github.com/json-iterator/go" + packages = ["."] + revision = "1624edc4454b8682399def8740d46db5e4362ba4" + version = "v1.1.5" + +[[projects]] + name = "github.com/matttproud/golang_protobuf_extensions" + packages = ["pbutil"] + revision = "c12348ce28de40eed0136aa2b644d0ee0650e56c" + version = "v1.0.1" + +[[projects]] + name = "github.com/modern-go/concurrent" + packages = ["."] + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + name = "github.com/modern-go/reflect2" + packages = ["."] + revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd" + version = "1.0.1" + +[[projects]] + branch = "master" + name = "github.com/petar/GoLLRB" + packages = ["llrb"] + revision = "53be0d36a84c2a886ca057d34b6aa4468df9ccb4" + +[[projects]] + name = "github.com/peterbourgon/diskv" + packages = ["."] + revision = "5f041e8faa004a95c88a202771f4cc3e991971e6" + version = "v2.0.1" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/prometheus/client_golang" + packages = [ + "api", + "api/prometheus/v1", + "prometheus", + "prometheus/internal", + "prometheus/promhttp" + ] + revision = "abad2d1bd44235a26707c172eab6bca5bf2dbad3" + version = "v0.9.1" + +[[projects]] + branch = "master" + name = "github.com/prometheus/client_model" + packages = ["go"] + revision = "5c3871d89910bfb32f5fcab2aa4b9ec68e65a99f" + +[[projects]] + branch = "master" + name = "github.com/prometheus/common" + packages = [ + "expfmt", + "internal/bitbucket.org/ww/goautoneg", + "model" + ] + revision = "7e9e6cabbd393fc208072eedef99188d0ce788b6" + +[[projects]] + branch = "master" + name = "github.com/prometheus/procfs" + packages = [ + ".", + "internal/util", + "nfs", + "xfs" + ] + revision = "185b4288413d2a0dd0806f78c90dde719829e5ae" + +[[projects]] + name = "github.com/spf13/cobra" + packages = ["."] + revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385" + version = "v0.0.3" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "298182f68c66c05229eb03ac171abe6e309ee79a" + version = "v1.0.3" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "require" + ] + revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686" + version = "v1.2.2" + +[[projects]] + name = "go.opencensus.io" + packages = [ + ".", + "exemplar", + "exporter/prometheus", + "internal", + "internal/tagencoding", + "plugin/ochttp", + "plugin/ochttp/propagation/b3", + "stats", + "stats/internal", + "stats/view", + "tag", + "trace", + "trace/internal", + "trace/propagation", + "trace/tracestate" + ] + revision = "b7bf3cdb64150a8c8c53b769fdeb2ba581bd4d4b" + version = "v0.18.0" + +[[projects]] + name = "go.uber.org/atomic" + packages = ["."] + revision = "1ea20fb1cbb1cc08cbd0d913a96dead89aa18289" + version = "v1.3.2" + +[[projects]] + name = "go.uber.org/multierr" + packages = ["."] + revision = "3c4937480c32f4c13a875a1829af76c98ca3d40a" + version = "v1.1.0" + +[[projects]] + name = "go.uber.org/zap" + packages = [ + ".", + "buffer", + "internal/bufferpool", + "internal/color", + "internal/exit", + "zapcore" + ] + revision = "ff33455a0e382e8a81d14dd7c922020b6b5e7982" + version = "v1.9.1" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "4d3f4d9ffa16a13f451c3b2999e9c49e9750bf06" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "context/ctxhttp", + "http/httpguts", + "http2", + "http2/hpack", + "idna", + "internal/timeseries", + "trace" + ] + revision = "c44066c5c816ec500d459a2a324a753f78531ae0" + +[[projects]] + branch = "master" + name = "golang.org/x/oauth2" + packages = [ + ".", + "google", + "internal", + "jws", + "jwt" + ] + revision = "8527f56f71077909d6ead7facfe18fbf05ebdf83" + +[[projects]] + branch = "master" + name = "golang.org/x/sync" + packages = ["errgroup"] + revision = "42b317875d0fa942474b76e1b46a6060d720ae6e" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "c8e336422fdcf1a7abeb865a23da98be0d8e2bc7" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "golang.org/x/time" + packages = ["rate"] + revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" + +[[projects]] + branch = "master" + name = "golang.org/x/tools" + packages = [ + "go/ast/astutil", + "imports", + "internal/fastwalk", + "internal/gopathwalk" + ] + revision = "a0a13e073c7bae39af55369bcd1c2dc7ebb88ede" + +[[projects]] + branch = "master" + name = "google.golang.org/api" + packages = [ + "gensupport", + "googleapi", + "googleapi/internal/uritemplates", + "googleapi/transport", + "internal", + "iterator", + "option", + "storage/v1", + "transport/http", + "transport/http/internal/propagation" + ] + revision = "0a71a4356c3f4bcbdd16294c78ca2a31fda36cca" + +[[projects]] + name = "google.golang.org/appengine" + packages = [ + ".", + "internal", + "internal/app_identity", + "internal/base", + "internal/datastore", + "internal/log", + "internal/modules", + "internal/remote_api", + "internal/urlfetch", + "urlfetch" + ] + revision = "ae0ab99deb4dc413a2b4bd6c8bdd0eb67f1e4d06" + version = "v1.2.0" + +[[projects]] + branch = "master" + name = "google.golang.org/genproto" + packages = [ + "googleapis/api/annotations", + "googleapis/iam/v1", + "googleapis/rpc/code", + "googleapis/rpc/status" + ] + revision = "c830210a61dfaa790e1920f8d0470fc27bc2efbe" + +[[projects]] + name = "google.golang.org/grpc" + packages = [ + ".", + "balancer", + "balancer/base", + "balancer/roundrobin", + "codes", + "connectivity", + "credentials", + "encoding", + "encoding/proto", + "grpclog", + "internal", + "internal/backoff", + "internal/channelz", + "internal/envconfig", + "internal/grpcrand", + "internal/transport", + "keepalive", + "metadata", + "naming", + "peer", + "resolver", + "resolver/dns", + "resolver/passthrough", + "stats", + "status", + "tap" + ] + revision = "2e463a05d100327ca47ac218281906921038fd95" + version = "v1.16.0" + +[[projects]] + name = "gopkg.in/inf.v0" + packages = ["."] + revision = "d2d2541c53f18d2a059457998ce2876cc8e67cbf" + version = "v0.9.1" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" + +[[projects]] + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "scheduling/v1beta1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1" + ] + revision = "37c5ce6f2f592fbbd798bb86a8814d0918b3abe1" + version = "kubernetes-1.11.4" + +[[projects]] + branch = "release-1.11" + name = "k8s.io/apimachinery" + packages = [ + "pkg/api/errors", + "pkg/api/meta", + "pkg/api/resource", + "pkg/apis/meta/internalversion", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/apis/meta/v1beta1", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/cache", + "pkg/util/clock", + "pkg/util/diff", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/mergepatch", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/strategicpatch", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/json", + "third_party/forked/golang/reflect" + ] + revision = "8ee1a638bafa4ae9691077e690cb45dd54f45111" + +[[projects]] + name = "k8s.io/client-go" + packages = [ + "discovery", + "discovery/fake", + "informers", + "informers/admissionregistration", + "informers/admissionregistration/v1alpha1", + "informers/admissionregistration/v1beta1", + "informers/apps", + "informers/apps/v1", + "informers/apps/v1beta1", + "informers/apps/v1beta2", + "informers/autoscaling", + "informers/autoscaling/v1", + "informers/autoscaling/v2beta1", + "informers/batch", + "informers/batch/v1", + "informers/batch/v1beta1", + "informers/batch/v2alpha1", + "informers/certificates", + "informers/certificates/v1beta1", + "informers/core", + "informers/core/v1", + "informers/events", + "informers/events/v1beta1", + "informers/extensions", + "informers/extensions/v1beta1", + "informers/internalinterfaces", + "informers/networking", + "informers/networking/v1", + "informers/policy", + "informers/policy/v1beta1", + "informers/rbac", + "informers/rbac/v1", + "informers/rbac/v1alpha1", + "informers/rbac/v1beta1", + "informers/scheduling", + "informers/scheduling/v1alpha1", + "informers/scheduling/v1beta1", + "informers/settings", + "informers/settings/v1alpha1", + "informers/storage", + "informers/storage/v1", + "informers/storage/v1alpha1", + "informers/storage/v1beta1", + "kubernetes", + "kubernetes/scheme", + "kubernetes/typed/admissionregistration/v1alpha1", + "kubernetes/typed/admissionregistration/v1beta1", + "kubernetes/typed/apps/v1", + "kubernetes/typed/apps/v1beta1", + "kubernetes/typed/apps/v1beta2", + "kubernetes/typed/authentication/v1", + "kubernetes/typed/authentication/v1beta1", + "kubernetes/typed/authorization/v1", + "kubernetes/typed/authorization/v1beta1", + "kubernetes/typed/autoscaling/v1", + "kubernetes/typed/autoscaling/v2beta1", + "kubernetes/typed/batch/v1", + "kubernetes/typed/batch/v1beta1", + "kubernetes/typed/batch/v2alpha1", + "kubernetes/typed/certificates/v1beta1", + "kubernetes/typed/core/v1", + "kubernetes/typed/events/v1beta1", + "kubernetes/typed/extensions/v1beta1", + "kubernetes/typed/networking/v1", + "kubernetes/typed/policy/v1beta1", + "kubernetes/typed/rbac/v1", + "kubernetes/typed/rbac/v1alpha1", + "kubernetes/typed/rbac/v1beta1", + "kubernetes/typed/scheduling/v1alpha1", + "kubernetes/typed/scheduling/v1beta1", + "kubernetes/typed/settings/v1alpha1", + "kubernetes/typed/storage/v1", + "kubernetes/typed/storage/v1alpha1", + "kubernetes/typed/storage/v1beta1", + "listers/admissionregistration/v1alpha1", + "listers/admissionregistration/v1beta1", + "listers/apps/v1", + "listers/apps/v1beta1", + "listers/apps/v1beta2", + "listers/autoscaling/v1", + "listers/autoscaling/v2beta1", + "listers/batch/v1", + "listers/batch/v1beta1", + "listers/batch/v2alpha1", + "listers/certificates/v1beta1", + "listers/core/v1", + "listers/events/v1beta1", + "listers/extensions/v1beta1", + "listers/networking/v1", + "listers/policy/v1beta1", + "listers/rbac/v1", + "listers/rbac/v1alpha1", + "listers/rbac/v1beta1", + "listers/scheduling/v1alpha1", + "listers/scheduling/v1beta1", + "listers/settings/v1alpha1", + "listers/storage/v1", + "listers/storage/v1alpha1", + "listers/storage/v1beta1", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/apis/clientauthentication/v1beta1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "plugin/pkg/client/auth/gcp", + "rest", + "rest/watch", + "testing", + "third_party/forked/golang/template", + "tools/auth", + "tools/cache", + "tools/clientcmd", + "tools/clientcmd/api", + "tools/clientcmd/api/latest", + "tools/clientcmd/api/v1", + "tools/metrics", + "tools/pager", + "tools/record", + "tools/reference", + "transport", + "util/buffer", + "util/cert", + "util/connrotation", + "util/flowcontrol", + "util/homedir", + "util/integer", + "util/jsonpath", + "util/retry", + "util/workqueue" + ] + revision = "3db8bfc8858dc9a5d6e7ef5817f58a7ca30b0c6a" + version = "kubernetes-1.11.4" + +[[projects]] + branch = "release-1.11" + name = "k8s.io/code-generator" + packages = [ + "cmd/client-gen", + "cmd/client-gen/args", + "cmd/client-gen/generators", + "cmd/client-gen/generators/fake", + "cmd/client-gen/generators/scheme", + "cmd/client-gen/generators/util", + "cmd/client-gen/path", + "cmd/client-gen/types", + "pkg/util" + ] + revision = "8c97d6ab64da020f8b151e9d3ed8af3172f5c390" + +[[projects]] + branch = "master" + name = "k8s.io/gengo" + packages = [ + "args", + "generator", + "namer", + "parser", + "types" + ] + revision = "7338e4bfd6915369a1375890db1bbda0158c9863" + +[[projects]] + branch = "master" + name = "k8s.io/kube-openapi" + packages = ["pkg/util/proto"] + revision = "0d1aeffe1c68f49accbd05c185ae534fe1372a3f" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "afbd1e4e846fd871664c2620d98badea04974915c613d86e143ec755b1b92d8b" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..4f5d466 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,57 @@ +required = [ + "k8s.io/code-generator/cmd/client-gen" +] + +[[constraint]] + name = "go.uber.org/zap" + version = "1.9.1" + +[[constraint]] + name = "github.com/spf13/cobra" + version = "=v0.0.3" + +[[constraint]] + name = "github.com/stretchr/testify" + version = "1.2.2" + +[[constraint]] + name = "go.opencensus.io" + version = "=v0.18.0" + +[[constraint]] + name = "github.com/prometheus/client_golang" + version = "v0.9.1" + +[[constraint]] + name = "k8s.io/client-go" + version = "kubernetes-1.11.4" + +[[constraint]] + name = "k8s.io/apimachinery" + version = "kubernetes-1.11.4" + +[[constraint]] + name = "k8s.io/api" + version = "kubernetes-1.11.4" + +[[constraint]] + name = "k8s.io/code-generator" + version = "kubernetes-1.11.4" + +[prune] + non-go = true + go-tests = true + unused-packages = true + + [[prune.project]] + name = "k8s.io/code-generator" + unused-packages = false + non-go = false + go-tests = false + + [[prune.project]] + name = "k8s.io/gengo" + unused-packages = false + non-go = false + go-tests = false + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..70a29fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2018 Le Van Nghia + +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/Makefile b/Makefile new file mode 100644 index 0000000..2525d2a --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +.PHONY: build +build: + bazel build -k -- //cmd/... //pkg/... + +.PHONY: test +test: + bazel test -- //cmd/... //pkg/... + +.PHONY: push +push: + bazel run --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //cmd:push_all_images + +.PHONY: dep +dep: + dep ensure + bazel run //:gazelle -- update-repos -from_file=Gopkg.lock + +.PHONY: gazelle +gazelle: + bazel run //:gazelle + +.PHONY: codegen +codegen: + ./hack/update-codegen.sh + +.PHONY: libsonnet +libsonnet: + jb update --jsonnetpkg-home=libsonnet + +.PHONY: install +install: + helm install --name lotus -f ./install/values.yaml ./install/helm + +.PHONY: upgrade +upgrade: + helm upgrade lotus -f ./install/values.yaml ./install/helm + +.PHONY: generate-manifests +generate-manifests: + ./hack/generate-manifests.sh + ./hack/generate-manifests.sh norbac + +.PHONY: generate-dashboards +generate-dashboards: + ./hack/generate-dashboards.sh diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..202b69c --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,10 @@ +In this project, I used and modified the following files from the Kubernetes project. +So those files are containing the LICENSE from Kubernetes project. + +Lotus: +- https://github.com/nghialv/lotus/blob/master/hack/print-workspace-status.sh +- https://github.com/nghialv/lotus/blob/master/pkg/version/def.bzl + +Kubernetes: +- https://github.com/kubernetes/kubernetes/blob/master/hack/print-workspace-status.sh +- https://github.com/kubernetes/kubernetes/blob/master/pkg/version/def.bzl diff --git a/README.md b/README.md new file mode 100644 index 0000000..adbac51 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# Lotus [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com) [![MIT Licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/nghialv/lotus/blob/master/LICENSE) + +Lotus is a Kubernetes controller for running load testing. Lotus schedules & monitors the load test workers, collects & stores the metrics and notifies the test result. + +Once installed, Lotus provides the following features: +- GRPC and HTTP support +- Ability to write the scenario by any language you want +- Automation-friendly + - `Checks` (like asserts, fails in normal test) for easy and flexible CI configuration + - Test is configured by using declarative Kubernetes CRD for version control friendliness +- Flexible metrics storage and visualization + - Ability to view visualized time series by Grafana + - Ability to persist time series data to a long-term storage like GCS or S3 + - Ability to notify and store the result summary to multiple receivers: GCS, Slack, Logger... + +> _I am thinking about adding a feature that helps us determine the maximum number of users (requests) the target services can handle. This can be done by automatically running the load tests with the number of virtual users increasing gradually until one of the checks fails. Or a feature that helps us determine the needed resources of the target services so that they can handle the given number of users. [more](https://github.com/nghialv/lotus/issues/1)_ + +### Installation +Firstly, you need to install Lotus controller on your Kubernetes cluster to start using. +Lotus requires a Kubernetes cluster of version `>=1.9.0`. + +The Lotus controller can be installed either by using the helm [`chart`](https://github.com/nghialv/lotus/tree/master/install/helm) or by using Kubernetes [`manifests`](https://github.com/nghialv/lotus/tree/master/install/manifests) directly. +(Using the helm chart is recommended.) + +``` console +helm install --name lotus ./install/helm +``` + +See [`install`](https://github.com/nghialv/lotus/tree/master/install) for more details. + +### Running Lotus +We have 2 steps to start running a load test: +- Writing a load test scenario +- Writing a Lotus CRD configuration + +#### 1. Writing a load test scenario + +Theoretically, you can write your scenarios by using any language you like. The only thing you need to have is a metrics exporter for Prometheus. + +In the case of Golang, I have already prepared some util packages (e.g. [`metrics`](https://github.com/nghialv/lotus/tree/master/pkg/metrics), [`virtualuser`](https://github.com/nghialv/lotus/tree/master/pkg/virtualuser)) that help you write your scenarios faster and easier. + +- Expose a metrics server in your scenario's `main.go` +``` go +import "github.com/nghialv/lotus/pkg/metrics" + +m, err := metrics.NewServer(8081) +if err != nil { + return err +} +defer m.Stop() +go m.Run() +``` +- In case you want to send gRPC's rpcs to your load server, let's set `grpcmetrics.ClientHandler` as the `StatsHandler` of your gRPC connection. +``` go +grpc.Dial( + grpc.WithStatsHandler(&grpcmetrics.ClientHandler{}), +) +``` + +- In case you want to send HTTP requests to your load server, let's use the `Transport` from `httpmetrics` package. +``` go +http.Client{ + Transport: &httpmetrics.Transport{}, +} +``` +- That is all. Now let's build your scenario image and publish to your container registry. + +#### 2. Writing a Lotus CRD configuration + +``` yaml +apiVersion: lotus.nghialv.com/v1beta1 +kind: Lotus +metadata: + name: simple-scenario-12345 // The unique testID +spec: + worker: + runTime: 10m // How long the load test will be run + replicas: 15 // How many workers should be created + metricsPort: 8081 // What port number should be used to collect metrics + containers: + - name: worker + image: your-registry/your-worker-image // The scenario image you published above + ports: + - name: metrics + containerPort: 8081 + checks: // You can add some checks to be checked while running + - name: GRPCHighErrorRate + expr: lotus_grpc_client_failure_percentage > 10 + for: 30s +``` + +Then apply this file to your Kubernetes cluster. Lotus will handle this test for you. + +See [`crd-configurations.md`](https://github.com/nghialv/lotus/blob/master/docs/lotus-crd-configurations.md) for all configurable fields. + +See [`examples`](https://github.com/nghialv/lotus/tree/master/examples) for more examples. + +### Outputs + +- Test summary + +Lotus collects the metrics data and evaluates the `checks` to build a summary result for each test. +Lotus can be configured to upload this summary file to external services (e.g: GCS, Slack...) or to log into `stdout`. +3 formats of the summary file are supported: `Text`, `Markdown`, `JSON`. + +``` yaml +TestID: test-scenario-12345 +TestStatus: Succeeded +Start: 09:02:59 2018-12-03 +End: 09:12:59 2018-12-03 + +MetricsSummary: + +1. Virtual User + - Started: 1M + - Failed: 0 + +2. GRPC + - RPCTotal: 25M + - FailurePercentage: 2.507 + +GroupByMethod: + RPCs Failure% Latency SentBytes RecvBytes + + - helloworld.Hello 12.5M 1.015 105 15 8 + - helloworld.Profile 12.5M 1.415 152 8 256 + - all 25M 1.207 135 12 245 + +Grafana: http://localhost:3000/dashboard/db/grpc?from=1543827779598&to=1543828379598 +``` + + +- Grafana dashboards + +To be able to fully explore and understand your test, Lotus is providing some Grafana dashboards to view the visualizations of the metrics. +You can also set up Lotus to persist the time series data to a long-term storage (GCS or S3) for accessing after the test is deleted. + +- Test Status + +After applying the Lotus CRD to your Kubernetes cluster you can also use the following command to check the status of your test. + +``` console +kubectl describe Lotus your-lotus-name +``` + +Your test can be one of these status: `Pending`, `Preparing`, `Running`, `Cleaning`, `FailureCleaning`, `Failed`, `Succeeded` + +### Examples + +Please checkout [`/examples`](https://github.com/nghialv/lotus/tree/master/examples) directory that contains some prepared examples. + +### FQA + +Refer to [FQA.md](https://github.com/nghialv/lotus/blob/master/docs/fqa.md) + +### Development + +Refer to [development.md](https://github.com/nghialv/lotus/blob/master/docs/development.md) + +### LICENSE +Lotus is released under the MIT license. See [LICENSE](https://github.com/nghialv/lotus/blob/master/LICENSE) file for the details. diff --git a/WORKSPACE b/WORKSPACE new file mode 100644 index 0000000..3a4a34d --- /dev/null +++ b/WORKSPACE @@ -0,0 +1,394 @@ +workspace( + name = "lotus", +) + +load( + "@bazel_tools//tools/build_defs/repo:http.bzl", + "http_archive", +) + +# Rules go +http_archive( + name = "io_bazel_rules_go", + urls = ["https://github.com/bazelbuild/rules_go/releases/download/0.16.2/rules_go-0.16.2.tar.gz"], + sha256 = "f87fa87475ea107b3c69196f39c82b7bbf58fe27c62a338684c20ca17d1d8613", +) + +load( + "@io_bazel_rules_go//go:def.bzl", + "go_rules_dependencies", + "go_register_toolchains", +) + +go_rules_dependencies() + +go_register_toolchains( + go_version = "1.11.2", +) + +# Gazelle +http_archive( + name = "bazel_gazelle", + urls = ["https://github.com/bazelbuild/bazel-gazelle/releases/download/0.15.0/bazel-gazelle-0.15.0.tar.gz"], + sha256 = "6e875ab4b6bf64a38c352887760f21203ab054676d9c1b274963907e0768740d", +) + +load( + "@bazel_gazelle//:deps.bzl", + "gazelle_dependencies", + "go_repository", +) + +gazelle_dependencies() + +# Docker +http_archive( + name = "io_bazel_rules_docker", + sha256 = "29d109605e0d6f9c892584f07275b8c9260803bf0c6fcb7de2623b2bedc910bd", + strip_prefix = "rules_docker-0.5.1", + urls = ["https://github.com/bazelbuild/rules_docker/archive/v0.5.1.tar.gz"], +) + +load( + "@io_bazel_rules_docker//go:image.bzl", + _go_image_repos = "repositories", +) + +_go_image_repos() + +# Protoc-gen-validate +go_repository( + name = "com_lyft_protoc_gen_validate", + tag = "v0.0.11", + importpath = "github.com/lyft/protoc-gen-validate", + #build_file_proto_mode = "disable", +) + +# Below is the list autogenerated go_repository. + +go_repository( + name = "com_github_davecgh_go_spew", + commit = "8991bc29aa16c548c550c7ff78260e27b9ab7c73", + importpath = "github.com/davecgh/go-spew", +) + +go_repository( + name = "com_github_pmezard_go_difflib", + commit = "792786c7400a136282c1664665ae0a8db921c6c2", + importpath = "github.com/pmezard/go-difflib", +) + +go_repository( + name = "com_github_stretchr_testify", + commit = "f35b8ab0b5a2cef36673838d662e249dd9c94686", + importpath = "github.com/stretchr/testify", +) + +go_repository( + name = "org_uber_go_atomic", + commit = "1ea20fb1cbb1cc08cbd0d913a96dead89aa18289", + importpath = "go.uber.org/atomic", +) + +go_repository( + name = "org_uber_go_multierr", + commit = "3c4937480c32f4c13a875a1829af76c98ca3d40a", + importpath = "go.uber.org/multierr", +) + +go_repository( + name = "org_uber_go_zap", + commit = "ff33455a0e382e8a81d14dd7c922020b6b5e7982", + importpath = "go.uber.org/zap", +) + +go_repository( + name = "com_github_ghodss_yaml", + commit = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7", + importpath = "github.com/ghodss/yaml", +) + +go_repository( + name = "com_github_gogo_protobuf", + commit = "636bf0302bc95575d69441b25a2603156ffdddf1", + importpath = "github.com/gogo/protobuf", +) + +go_repository( + name = "com_github_golang_glog", + commit = "23def4e6c14b4da8ac2ed8007337bc5eb5007998", + importpath = "github.com/golang/glog", +) + +go_repository( + name = "com_github_golang_protobuf", + commit = "aa810b61a9c79d51363740d207bb46cf8e620ed5", + importpath = "github.com/golang/protobuf", +) + +go_repository( + name = "com_github_google_btree", + commit = "4030bb1f1f0c35b30ca7009e9ebd06849dd45306", + importpath = "github.com/google/btree", +) + +go_repository( + name = "com_github_google_gofuzz", + commit = "24818f796faf91cd76ec7bddd72458fbced7a6c1", + importpath = "github.com/google/gofuzz", +) + +go_repository( + name = "com_github_googleapis_gnostic", + build_file_proto_mode = "disable", + commit = "7c663266750e7d82587642f65e60bc4083f1f84e", + importpath = "github.com/googleapis/gnostic", +) + +go_repository( + name = "com_github_gregjones_httpcache", + commit = "9cad4c3443a7200dd6400aef47183728de563a38", + importpath = "github.com/gregjones/httpcache", +) + +go_repository( + name = "com_github_imdario_mergo", + commit = "9f23e2d6bd2a77f959b2bf6acdbefd708a83a4a4", + importpath = "github.com/imdario/mergo", +) + +go_repository( + name = "com_github_json_iterator_go", + commit = "1624edc4454b8682399def8740d46db5e4362ba4", + importpath = "github.com/json-iterator/go", +) + +go_repository( + name = "com_github_modern_go_concurrent", + commit = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94", + importpath = "github.com/modern-go/concurrent", +) + +go_repository( + name = "com_github_modern_go_reflect2", + commit = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd", + importpath = "github.com/modern-go/reflect2", +) + +go_repository( + name = "com_github_petar_gollrb", + commit = "53be0d36a84c2a886ca057d34b6aa4468df9ccb4", + importpath = "github.com/petar/GoLLRB", +) + +go_repository( + name = "com_github_peterbourgon_diskv", + commit = "5f041e8faa004a95c88a202771f4cc3e991971e6", + importpath = "github.com/peterbourgon/diskv", +) + +go_repository( + name = "com_github_spf13_pflag", + commit = "298182f68c66c05229eb03ac171abe6e309ee79a", + importpath = "github.com/spf13/pflag", +) + +go_repository( + name = "in_gopkg_inf_v0", + commit = "d2d2541c53f18d2a059457998ce2876cc8e67cbf", + importpath = "gopkg.in/inf.v0", +) + +go_repository( + name = "in_gopkg_yaml_v2", + commit = "5420a8b6744d3b0345ab293f6fcba19c978f1183", + importpath = "gopkg.in/yaml.v2", +) + +go_repository( + name = "io_k8s_api", + build_file_proto_mode = "disable", + commit = "37c5ce6f2f592fbbd798bb86a8814d0918b3abe1", + importpath = "k8s.io/api", +) + +go_repository( + name = "io_k8s_apimachinery", + build_file_proto_mode = "disable", + commit = "8ee1a638bafa4ae9691077e690cb45dd54f45111", + importpath = "k8s.io/apimachinery", +) + +go_repository( + name = "io_k8s_client_go", + commit = "3db8bfc8858dc9a5d6e7ef5817f58a7ca30b0c6a", + importpath = "k8s.io/client-go", +) + +go_repository( + name = "org_golang_google_appengine", + commit = "ae0ab99deb4dc413a2b4bd6c8bdd0eb67f1e4d06", + importpath = "google.golang.org/appengine", +) + +go_repository( + name = "org_golang_x_crypto", + commit = "4d3f4d9ffa16a13f451c3b2999e9c49e9750bf06", + importpath = "golang.org/x/crypto", +) + +go_repository( + name = "org_golang_x_net", + commit = "c44066c5c816ec500d459a2a324a753f78531ae0", + importpath = "golang.org/x/net", +) + +go_repository( + name = "org_golang_x_oauth2", + commit = "8527f56f71077909d6ead7facfe18fbf05ebdf83", + importpath = "golang.org/x/oauth2", +) + +go_repository( + name = "org_golang_x_sys", + commit = "c8e336422fdcf1a7abeb865a23da98be0d8e2bc7", + importpath = "golang.org/x/sys", +) + +go_repository( + name = "org_golang_x_text", + commit = "f21a4dfb5e38f5895301dc265a8def02365cc3d0", + importpath = "golang.org/x/text", +) + +go_repository( + name = "org_golang_x_time", + commit = "fbb02b2291d28baffd63558aa44b4b56f178d650", + importpath = "golang.org/x/time", +) + +go_repository( + name = "io_k8s_code_generator", + commit = "8c97d6ab64da020f8b151e9d3ed8af3172f5c390", + importpath = "k8s.io/code-generator", +) + +go_repository( + name = "io_k8s_gengo", + commit = "7338e4bfd6915369a1375890db1bbda0158c9863", + importpath = "k8s.io/gengo", +) + +go_repository( + name = "org_golang_x_tools", + commit = "a0a13e073c7bae39af55369bcd1c2dc7ebb88ede", + importpath = "golang.org/x/tools", +) + +go_repository( + name = "com_github_hashicorp_golang_lru", + commit = "20f1fb78b0740ba8c3cb143a61e86ba5c8669768", + importpath = "github.com/hashicorp/golang-lru", +) + +go_repository( + name = "io_k8s_kube_openapi", + commit = "0d1aeffe1c68f49accbd05c185ae534fe1372a3f", + importpath = "k8s.io/kube-openapi", +) + +go_repository( + name = "com_github_golang_groupcache", + commit = "c65c006176ff7ff98bb916961c7abbc6b0afc0aa", + importpath = "github.com/golang/groupcache", +) + +go_repository( + name = "com_google_cloud_go", + commit = "debcad1964693daf8ef4bc06292d7e828e075130", + importpath = "cloud.google.com/go", +) + +go_repository( + name = "com_github_beorn7_perks", + commit = "3a771d992973f24aa725d07868b467d1ddfceafb", + importpath = "github.com/beorn7/perks", +) + +go_repository( + name = "com_github_matttproud_golang_protobuf_extensions", + commit = "c12348ce28de40eed0136aa2b644d0ee0650e56c", + importpath = "github.com/matttproud/golang_protobuf_extensions", +) + +go_repository( + name = "com_github_prometheus_client_golang", + commit = "abad2d1bd44235a26707c172eab6bca5bf2dbad3", + importpath = "github.com/prometheus/client_golang", +) + +go_repository( + name = "com_github_prometheus_client_model", + commit = "5c3871d89910bfb32f5fcab2aa4b9ec68e65a99f", + importpath = "github.com/prometheus/client_model", +) + +go_repository( + name = "com_github_prometheus_common", + commit = "7e9e6cabbd393fc208072eedef99188d0ce788b6", + importpath = "github.com/prometheus/common", +) + +go_repository( + name = "com_github_prometheus_procfs", + commit = "185b4288413d2a0dd0806f78c90dde719829e5ae", + importpath = "github.com/prometheus/procfs", +) + +go_repository( + name = "io_opencensus_go", + commit = "b7bf3cdb64150a8c8c53b769fdeb2ba581bd4d4b", + importpath = "go.opencensus.io", +) + +go_repository( + name = "org_golang_google_genproto", + commit = "c830210a61dfaa790e1920f8d0470fc27bc2efbe", + importpath = "google.golang.org/genproto", +) + +go_repository( + name = "org_golang_google_grpc", + commit = "2e463a05d100327ca47ac218281906921038fd95", + importpath = "google.golang.org/grpc", +) + +go_repository( + name = "org_golang_x_sync", + commit = "42b317875d0fa942474b76e1b46a6060d720ae6e", + importpath = "golang.org/x/sync", +) + +go_repository( + name = "com_github_googleapis_gax_go", + commit = "b001040cd31805261cbd978842099e326dfa857b", + importpath = "github.com/googleapis/gax-go", +) + +go_repository( + name = "org_golang_google_api", + commit = "0a71a4356c3f4bcbdd16294c78ca2a31fda36cca", + importpath = "google.golang.org/api", +) + +go_repository( + name = "com_github_inconshreveable_mousetrap", + commit = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75", + importpath = "github.com/inconshreveable/mousetrap", +) + +go_repository( + name = "com_github_spf13_cobra", + commit = "ef82de70bb3f60c65fb8eebacbb2d122ef517385", + importpath = "github.com/spf13/cobra", +) diff --git a/cmd/BUILD.bazel b/cmd/BUILD.bazel new file mode 100644 index 0000000..e5ee396 --- /dev/null +++ b/cmd/BUILD.bazel @@ -0,0 +1,15 @@ +load(":image.bzl", "all_images") +load("@io_bazel_rules_docker//container:container.bzl", "container_bundle") + +container_bundle( + name = "bundle_to_push", + images = all_images(), + stamp = True, +) + +load("@io_bazel_rules_docker//contrib:push-all.bzl", "docker_push") + +docker_push( + name = "push_all_images", + bundle = ":bundle_to_push", +) diff --git a/cmd/example/BUILD.bazel b/cmd/example/BUILD.bazel new file mode 100644 index 0000000..9f9a079 --- /dev/null +++ b/cmd/example/BUILD.bazel @@ -0,0 +1,30 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") + +go_library( + name = "go_default_library", + srcs = ["main.go"], + importpath = "github.com/nghialv/lotus/cmd/example", + visibility = ["//visibility:private"], + deps = [ + "//pkg/app/example/cmd/helloworld:go_default_library", + "//pkg/app/example/cmd/simplegrpc:go_default_library", + "//pkg/app/example/cmd/simplehttp:go_default_library", + "//pkg/app/example/cmd/threesteps:go_default_library", + "//pkg/app/example/cmd/virtualuser:go_default_library", + "//pkg/cli:go_default_library", + ], +) + +go_binary( + name = "example", + embed = [":go_default_library"], + visibility = ["//visibility:public"], +) + +load("@io_bazel_rules_docker//go:image.bzl", "go_image") + +go_image( + name = "image", + binary = ":example", + visibility = ["//visibility:public"], +) diff --git a/cmd/example/main.go b/cmd/example/main.go new file mode 100644 index 0000000..5306af6 --- /dev/null +++ b/cmd/example/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "log" + + "github.com/nghialv/lotus/pkg/cli" + "github.com/nghialv/lotus/pkg/app/example/cmd/helloworld" + "github.com/nghialv/lotus/pkg/app/example/cmd/simplegrpc" + "github.com/nghialv/lotus/pkg/app/example/cmd/simplehttp" + "github.com/nghialv/lotus/pkg/app/example/cmd/threesteps" + "github.com/nghialv/lotus/pkg/app/example/cmd/virtualuser" +) + +func main() { + app := cli.NewApp( + "lotus-example", + "Example of using lotus.", + ) + app.AddCommands( + simplehttp.NewCommand(), + simplegrpc.NewCommand(), + threesteps.NewCommand(), + virtualuser.NewCommand(), + helloworld.NewCommand(), + ) + if err := app.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/image.bzl b/cmd/image.bzl new file mode 100644 index 0000000..bb5a922 --- /dev/null +++ b/cmd/image.bzl @@ -0,0 +1,12 @@ +def all_images(): + cmds = { + "lotus": "lotus", + "example": "lotus-example", + } + images = {} + + for cmd, repo in cmds.items(): + images["$(DOCKER_REGISTRY)/%s:{STABLE_GIT_COMMIT_FULL}" % repo] = "//cmd/%s:image" % cmd + images["$(DOCKER_REGISTRY)/%s:{STABLE_GIT_COMMIT}" % repo ] = "//cmd/%s:image" % cmd + + return images diff --git a/cmd/lotus/BUILD.bazel b/cmd/lotus/BUILD.bazel new file mode 100644 index 0000000..e794b0e --- /dev/null +++ b/cmd/lotus/BUILD.bazel @@ -0,0 +1,27 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") + +go_library( + name = "go_default_library", + srcs = ["main.go"], + importpath = "github.com/nghialv/lotus/cmd/lotus", + visibility = ["//visibility:private"], + deps = [ + "//pkg/app/lotus/cmd/controller:go_default_library", + "//pkg/app/lotus/cmd/monitor:go_default_library", + "//pkg/cli:go_default_library", + ], +) + +go_binary( + name = "lotus", + embed = [":go_default_library"], + visibility = ["//visibility:public"], +) + +load("@io_bazel_rules_docker//go:image.bzl", "go_image") + +go_image( + name = "image", + binary = ":lotus", + visibility = ["//visibility:public"], +) diff --git a/cmd/lotus/main.go b/cmd/lotus/main.go new file mode 100644 index 0000000..934bd14 --- /dev/null +++ b/cmd/lotus/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "log" + + "github.com/nghialv/lotus/pkg/cli" + "github.com/nghialv/lotus/pkg/app/lotus/cmd/controller" + "github.com/nghialv/lotus/pkg/app/lotus/cmd/monitor" +) + +func main() { + app := cli.NewApp( + "lotus", + "Load testing tool.", + ) + app.AddCommands( + controller.NewCommand(), + monitor.NewCommand(), + ) + if err := app.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2641d5d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,7 @@ +# Documentations + +- [Life of a Lotus CRD](https://github.com/nghialv/lotus/blob/master/docs/life-of-lotus-crd.md) +- [Controller's configurations](https://github.com/nghialv/lotus/blob/master/docs/configurations.md) +- [Lotus CRD's configurations](https://github.com/nghialv/lotus/blob/master/docs/lotus-crd-configurations.md) +- [Frequently Questioned Answers](https://github.com/nghialv/lotus/blob/master/docs/fqa.md) +- [How to contribute](https://github.com/nghialv/lotus/blob/master/docs/development.md) diff --git a/docs/configurations.md b/docs/configurations.md new file mode 100644 index 0000000..8620e57 --- /dev/null +++ b/docs/configurations.md @@ -0,0 +1,84 @@ +# Configurations + +This is an example of a full configuration file. + +``` yaml +lotus: + configs: + checks: // 1. The list of global checks. Those checks will be applied to all tests. + - name: NoWorker + expr: absent(up) + for: 30s + - name: HasWorkerDown + expr: up == 0 + for: 30s + - name: GRPCHighFailurePercentage + expr: lotus_grpc_client_completed_rpcs_failure_percentage:method > 5 + for: 30s + - name: HTTPHighFailurePercentage + expr: lotus_http_client_completed_requests_5xx_percentage:host:route:method > 5 + for: 30s + - name: VirtualUserHighFailurePercentage + expr: lotus_virtual_user_failure_percentage > 2 + for: 10s + receivers: // 2. The list of all receivers to send the summary result. + - name: gcs + gcs: + bucket: lotus-result-bucket + credentials: + secret: gcs-credentials + file: gcs-credentials.json + - name: lotus-slack-channel + slack: + hookUrl: https://hooks.slack.com/services/YOUR-HOOK + - name: logger + logger: + timeSeriesStorage: // 3. A long-term storage for storing time series data. + gcs: + bucket: lotus-timeseries-bucket + credentials: + secret: gcs-credentials + file: gcs-credentials.json + grafanaBaseUrl: http://your-grafana-domain:3000 +``` + +### 1. Global checks setup + +You can define the checks on your Lotus CRD for each test, but you can also define some global checks on the configuration file. +I recommend to add these 2 checks to your global checks: the first one is for checking if no worker has started, the second one is for checking if has any worker down. + +``` +lotus: + configs: + checks: + - name: NoWorker + expr: absent(up) + for: 30s + - name: HasWorkerDown + expr: up == 0 + for: 30s +``` + +### 2. Receivers setup + +Currently we are supporting 3 types of receiver: GCS, Slack, Logger. + +#### GCS + +To configure Google Cloud Storage as a receiver, you need to set receiver with GCS bucket name and k8s secret that contains the Google Application credentials. + +``` +receivers: + - name: gcs + gcs: + bucket: lotus-result-bucket // The bucket name created on Google Cloud Storage + credentials: + secret: gcs-credentials // The name of k8s secret that contains Google Application credentials + file: gcs-credentials.json // The credentials file name inside the secret +``` + + +### 3. Long term storage setup + +To able to access the time series data after your test is deleted you have to configure to store those time series data to a long-term storage like GCS, S3, Azure... +You can do that by adding configuration for `lotus.configs.timeSEriesStorage` field. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..3e07c49 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,55 @@ +# Development + +### Prerequisites + +- [bazel](https://github.com/bazelbuild/bazel) (>= `0.17.1`) +- [jsonnet](https://jsonnet.org/) (Only if you want to make a change for the grafana dashboards.) + +### Getting started + +- Building +``` console +make build +``` + +- Testing +``` console +make test +``` + +- Adding a new go dependency +``` console +### 1. Update Gopkg.toml file + +### 2. Fetch the dependency and update Gopkg.lock by running +make dep + +### 3. Update bazel's BUILD files by running +make gazelle +``` + +- Making a change on [`Lotus model`](https://github.com/nghialv/lotus/blob/master/pkg/app/lotus/apis/lotus/v1beta1/types.go) + +We are using [`code-generator`](https://github.com/kubernetes/code-generator) to generate a typed client, informers, listers and deep-copy functions for `Lotus model`. +Then after making a change on the `Lotus model` you have to run the following command to update the generated codes. +``` console +make codegen +``` +The following files and directories will be updated. + +``` +pkg/lotus/apis/lotus/v1beta1/zz_generated.deepcopy.go +pkg/lotus/client/ +``` + +- Making a change on grafana dashboards + +We are using `jsonnet` to do dashboard templating. The templates is located at [/install/dashboard-templates](https://github.com/nghialv/lotus/tree/master/install/dashboard-templates) + +``` console +### Regenerate grafana dashoards +make generate-dashboards + +### Regenerate kubernetes manifests with the new updates +make generate-manifests +``` diff --git a/docs/fqa.md b/docs/fqa.md new file mode 100644 index 0000000..f1e5287 --- /dev/null +++ b/docs/fqa.md @@ -0,0 +1,13 @@ +# Frequently Questioned Answers + +- Can we store the timeseries data of the metrics to a long-term storage? + + Yes. + +- What storage will be supported? + + GCS and S3 (near future) + +- What does `3m` mean in the summary? + +https://en.wikipedia.org/wiki/Metric_prefix diff --git a/docs/life-of-lotus-crd.md b/docs/life-of-lotus-crd.md new file mode 100644 index 0000000..03d49e6 --- /dev/null +++ b/docs/life-of-lotus-crd.md @@ -0,0 +1,3 @@ +# Life of a Lotus CRD + +![](https://github.com/nghialv/lotus/blob/master/docs/life-of-lotus-crd.png) diff --git a/docs/life-of-lotus-crd.png b/docs/life-of-lotus-crd.png new file mode 100644 index 0000000000000000000000000000000000000000..096e5af510919c8364d9aea78da13f99f478b7a7 GIT binary patch literal 264001 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

-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 literal 0 HcmV?d00001 diff --git a/docs/lotus-crd-configurations.md b/docs/lotus-crd-configurations.md new file mode 100644 index 0000000..d01ac9e --- /dev/null +++ b/docs/lotus-crd-configurations.md @@ -0,0 +1,57 @@ +# Lotus CRD Configurations + +The following is an example of the full configurations. + +``` yaml +apiVersion: lotus.nghialv.com/v1beta1 +kind: Lotus +metadata: + name: scenario-12345 +spec: + ttlSecondsAfterFinished: 300 + checkIntervalSeconds: 10 + checkInitialDelaySeconds: 15 + worker: + runTime: 30m + replicas: 20 + metricsPort: 8081 + containers: + - name: worker + image: nghialv2607/lotus-example:v0.1.0 + args: + - three-steps-scenario + - --step=worker + - --helloworld-grpc-address=helloworld:8080 + - --helloworld-http-address=http://helloworld:9090 + ports: + - name: metrics + containerPort: 8081 + volumeMounts: + - name: data + mountPath: /etc/data + volumes: + - name: data + configMap: + name: worker-data + preparer: + containers: + - name: preparer + image: nghialv2607/lotus-example:v0.1.0 + args: + - three-steps-scenario + - --step=preparer + cleaner: + containers: + - name: cleaner + image: nghialv2607/lotus-example:v0.1.0 + args: + - three-steps-scenario + - --step=cleaner + checks: + - name: GRPCHighLatency + expr: lotus_grpc_client_roundtrip_latency:method > 250 + for: 30s + - name: VirtualUserHighFailurePercentage + expr: lotus_virtual_user_failure_percentage > 10 + for: 10s +``` \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..942bfb7 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,39 @@ +# Examples + +### Prerequisites + +Before running one of these examples, you have to deploy [`helloworld`](https://github.com/nghialv/lotus/tree/master/examples/helloworld) target service to your cluster by running the following command. +``` console +kubectl apply -f /helloworld +``` +The implementation of `helloworld` server is here [helloworld.go](https://github.com/nghialv/lotus/blob/master/pkg/app/example/cmd/helloworld/helloworld.go) + +When running this service will start one grpc server for handling rpcs defined in [helloworld.proto](https://github.com/nghialv/lotus/blob/master/pkg/app/example/helloworld/helloworld.proto) and one HTTP server for handling incoming HTTP requests. + +### simple-http-scenario + +- Scenario: [`pkg/app/example/cmd/simplehttp/scenario.go`](https://github.com/nghialv/lotus/blob/master/pkg/app/example/cmd/simplehttp/scenario.go) +- Lotus CRD: [`simple-http-scenario.yaml`](https://github.com/nghialv/lotus/blob/master/examples/simple-http-scenario.yaml) + +A simple scenario that send one http request to `http://httpbin.org/`. + +### simple-grpc-scenario + +- Scenario: [`/pkg/app/example/cmd/simplegrpc/scenario.go`](https://github.com/nghialv/lotus/blob/master/pkg/app/example/cmd/simplegrpc/scenario.go) +- Lotus CRD: [`simple-grpc-scenario.yaml`](https://github.com/nghialv/lotus/blob/master/examples/simple-grpc-scenario.yaml) + +A simple scenario that send one grpc request to `helloworld` service. + +### three-steps-scenario + +- Scenario: [`/pkg/app/example/cmd/threesteps/scenario.go`](https://github.com/nghialv/lotus/blob/master/pkg/app/example/cmd/threesteps/scenario.go) +- Lotus CRD: [`three-steps-scenario.yaml`](https://github.com/nghialv/lotus/blob/master/examples/three-steps-scenario.yaml) + +An example containing full 3 steps of Lotus: `preparer`, `worker` and `cleaner`. + +### virtual-user-scenario + +- Scenario: [`/pkg/app/example/cmd/virtualuser/scenario.go`](https://github.com/nghialv/lotus/blob/master/pkg/app/example/cmd/virtualuser/scenario.go) +- Lotus CRD: [`virtualuser-scenario.yaml`](https://github.com/nghialv/lotus/blob/master/examples/virtualuser-scenario.yaml) + +An example using [`virtualuser`](https://github.com/nghialv/lotus/tree/master/pkg/virtualuser) package to spawn a given number of virtual users on each worker. diff --git a/examples/helloworld/deployment.yaml b/examples/helloworld/deployment.yaml new file mode 100644 index 0000000..96beb2d --- /dev/null +++ b/examples/helloworld/deployment.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: helloworld + labels: + app: helloworld +spec: + replicas: 2 + selector: + matchLabels: + app: helloworld + template: + metadata: + labels: + app: helloworld + spec: + containers: + - name: helloworld + image: nghialv2607/lotus-example:v0.1.0 + args: + - helloworld + ports: + - name: grpc + containerPort: 8080 + - name: http + containerPort: 9090 diff --git a/examples/helloworld/service.yaml b/examples/helloworld/service.yaml new file mode 100644 index 0000000..0fdd4fe --- /dev/null +++ b/examples/helloworld/service.yaml @@ -0,0 +1,14 @@ +kind: Service +apiVersion: v1 +metadata: + name: helloworld +spec: + selector: + app: helloworld + ports: + - name: grpc + protocol: TCP + port: 8080 + - name: http + protocol: TCP + port: 9090 diff --git a/examples/simple-grpc-scenario.yaml b/examples/simple-grpc-scenario.yaml new file mode 100644 index 0000000..c832f3c --- /dev/null +++ b/examples/simple-grpc-scenario.yaml @@ -0,0 +1,18 @@ +apiVersion: lotus.nghialv.com/v1beta1 +kind: Lotus +metadata: + name: simple-grpc-scenario-123456789 +spec: + worker: + runTime: 3m + replicas: 2 + metricsPort: 8081 + containers: + - name: worker + image: nghialv2607/lotus-example:v0.1.0 + args: + - simple-grpc-scenario + - --helloworld-grpc-address=helloworld:8080 + ports: + - name: metrics + containerPort: 8081 diff --git a/examples/simple-http-scenario.yaml b/examples/simple-http-scenario.yaml new file mode 100644 index 0000000..5d2c78e --- /dev/null +++ b/examples/simple-http-scenario.yaml @@ -0,0 +1,17 @@ +apiVersion: lotus.nghialv.com/v1beta1 +kind: Lotus +metadata: + name: simple-http-scenario-123456789 +spec: + worker: + runTime: 3m + replicas: 2 + metricsPort: 8081 + containers: + - name: worker + image: nghialv2607/lotus-example:v0.1.0 + args: + - simple-http-scenario + ports: + - name: metrics + containerPort: 8081 diff --git a/examples/three-steps-scenario.yaml b/examples/three-steps-scenario.yaml new file mode 100644 index 0000000..8c8e5ae --- /dev/null +++ b/examples/three-steps-scenario.yaml @@ -0,0 +1,45 @@ +apiVersion: lotus.nghialv.com/v1beta1 +kind: Lotus +metadata: + name: three-steps-scenario-1 +spec: + checkIntervalSeconds: 10 + worker: + runTime: 3m + replicas: 2 + metricsPort: 8081 + containers: + - name: worker + image: nghialv2607/lotus-example:v0.1.0 + args: + - three-steps-scenario + - --step=worker + - --helloworld-grpc-address=helloworld:8080 + - --helloworld-http-address=http://helloworld:9090 + ports: + - name: metrics + containerPort: 8081 + preparer: + containers: + - name: preparer + image: nghialv2607/lotus-example:v0.1.0 + args: + - three-steps-scenario + - --step=preparer + - --duration=10s + - --helloworld-grpc-address=helloworld:8080 + - --helloworld-http-address=http://helloworld:9090 + cleaner: + containers: + - name: cleaner + image: nghialv2607/lotus-example:v0.1.0 + args: + - three-steps-scenario + - --step=cleaner + - --duration=10s + - --helloworld-grpc-address=helloworld:8080 + - --helloworld-http-address=http://helloworld:9090 + checks: + - name: GRPCHighLatency + expr: lotus_grpc_client_roundtrip_latency:method > 2500 + for: 30s diff --git a/examples/virtualuser-scenario.yaml b/examples/virtualuser-scenario.yaml new file mode 100644 index 0000000..b75815d --- /dev/null +++ b/examples/virtualuser-scenario.yaml @@ -0,0 +1,25 @@ +apiVersion: lotus.nghialv.com/v1beta1 +kind: Lotus +metadata: + name: virtual-user-scenario-12345 +spec: + checkIntervalSeconds: 10 + worker: + runTime: 3m + replicas: 2 + metricsPort: 8081 + containers: + - name: worker + image: nghialv2607/lotus-example:v0.1.0 + args: + - virtual-user-scenario + - --num-virtual-users=100 + - --hatch-rate=10 + - --helloworld-grpc-address=helloworld:8080 + ports: + - name: metrics + containerPort: 8081 + checks: + - name: VirtualUserHasFailed + expr: lotus_virtual_user_count{virtual_user_status=~"failed"} > 0 + for: 1s diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt new file mode 100644 index 0000000..7ed7ed1 --- /dev/null +++ b/hack/boilerplate.go.txt @@ -0,0 +1,5 @@ +/* + +Generated by using code-generator + +*/ diff --git a/hack/generate-dashboards.sh b/hack/generate-dashboards.sh new file mode 100755 index 0000000..70790f5 --- /dev/null +++ b/hack/generate-dashboards.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +ROOT=$(dirname ${BASH_SOURCE})/.. +LIBSONNET_DIR="$ROOT/libsonnet" +TEMPLATES_DIR="$ROOT/install/dashboard-templates" +DASHBOARDS_DIR="$ROOT/install/helm/dashboards" + +mkdir -p $DASHBOARDS_DIR +rm -rf $DASHBOARDS_DIR/* + +for f in $(find $TEMPLATES_DIR -name "*-dashboard.jsonnet"); do + fn=${f##*/} + echo "Rendering $fn..." + jsonnet -J $LIBSONNET_DIR $f > $DASHBOARDS_DIR/${fn%.*}.json +done + diff --git a/hack/generate-manifests.sh b/hack/generate-manifests.sh new file mode 100755 index 0000000..3a02471 --- /dev/null +++ b/hack/generate-manifests.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +OPTION=${1:-""} +SUFFIX="" +if [ ! -z "$OPTION" ]; then + SUFFIX="-$OPTION" +fi + +ROOT=$(dirname ${BASH_SOURCE})/.. +MANIFESTS_DIR="${ROOT}/install/manifests${SUFFIX}" +VALUE_FILE="${ROOT}/install/manifest-generate-values${SUFFIX}.yaml" +HELM_CHART_DIR="${ROOT}/install/helm" + +echo "Generating manifests to tmp..." +helm template --name lotus -f $VALUE_FILE $HELM_CHART_DIR --output-dir /tmp + +echo "Deleting all old manifests..." +mkdir -p ${MANIFESTS_DIR} +rm -rf ${MANIFESTS_DIR}/* + +echo "Copying generated manifests to manifests folder..." +cp /tmp/lotus/templates/* ${MANIFESTS_DIR} +for f in $(find /tmp/lotus/charts/grafana/templates -type f); do + cp $f ${MANIFESTS_DIR}/grafana-${f##*/}; +done + +echo "Deleting tmp data..." +rm -rf /tmp/lotus + +echo "Done" + diff --git a/hack/print-workspace-status.sh b/hack/print-workspace-status.sh new file mode 100755 index 0000000..928264c --- /dev/null +++ b/hack/print-workspace-status.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +# Copyright 2017 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# See https://github.com/nghialv/lotus/tree/master/NOTICE.md + +set -o errexit +set -o nounset +set -o pipefail + +GIT_COMMIT="$(git describe --tags --always --dirty)" +GIT_COMMIT_FULL="$(git rev-parse HEAD)" +BUILD_DATE="$(date -u '+%Y%m%d')" +VERSION="${BUILD_DATE}-${GIT_COMMIT}" + +cat </dev/null || echo ../code-generator)} + +# generate the code with: +# --output-base because this script should also be able to run inside the vendor dir of +# k8s.io/kubernetes. The output-base is needed for the generators to output into the vendor dir +# instead of the $GOPATH directly. For normal projects this can be dropped. +${CODEGEN_PKG}/generate-groups.sh "deepcopy,client,informer,lister" \ + github.com/nghialv/lotus/pkg/app/lotus/client github.com/nghialv/lotus/pkg/app/lotus/apis \ + lotus:v1beta1 \ + --output-base "$(dirname ${BASH_SOURCE})/../../../.." \ + --go-header-file ${SCRIPT_ROOT}/hack/boilerplate.go.txt diff --git a/install/README.md b/install/README.md new file mode 100644 index 0000000..e5e7718 --- /dev/null +++ b/install/README.md @@ -0,0 +1,36 @@ +# Installation + +We are supporting 2 ways to install Lotus: +- using helm chart +- using kubernetes manifests directly + +**Using the helm chart is recommended.** + +### Using Helm chart + +The Lotus chart is put in `./helm` directory. + +``` +helm install --name lotus ./helm + +### If you want to override the values + +helm install --name lotus -f ./path/to/your/values.yaml ./helm +``` + +Please check out [`values.yaml`](https://github.com/nghialv/lotus/blob/master/install/helm/values.yaml) for configurable fields. +Note: Please change [`grafana.adminPassword`](https://github.com/nghialv/lotus/tree/master/install/helm/values.yaml#L27) value. The current password is `admin`. + +### Using kubernetes manifests + +All kubernetes manifests for Lotus are put in `./manifests` (RBAC is enabled) and `./manifests-norbac` (RBAC is disabled) directories. We generated those manifests from the helm chart above. + +``` +kubectl apply -f ./manifests + +### Or for disabling RBAC + +kubectl apply -f ./manifests-norbac +``` + +Note: Please change [`grafana adminPassword`](https://github.com/nghialv/lotus/tree/master/install/manifests/grafana-secret.yaml#L15) value to a `base64` encoded value. The current password is `admin`. \ No newline at end of file diff --git a/install/dashboard-templates/common.jsonnet b/install/dashboard-templates/common.jsonnet new file mode 100644 index 0000000..4e98b9d --- /dev/null +++ b/install/dashboard-templates/common.jsonnet @@ -0,0 +1,72 @@ +local grafana = import 'grafonnet/grafana.libsonnet'; +local template = grafana.template; +local graphPanel = grafana.graphPanel; +local text = grafana.text; + +{ + datasources:: { + default:: 'thanos', + }, + dashboard:: { + schemaVersion:: 16, + }, + tags:: { + grpc:: 'grpc', + http:: 'http', + }, + format:: { + short:: 'short', + second:: 's', + millisecond:: 'ms', + bytes:: 'bytes', + bytesPerSecond:: 'Bps', + percent_0_100:: 'percent', + percent_0_1:: 'percentunit', + }, + templates:: { + test:: template.new( + name='testId', + label='TestID', + datasource= $.datasources.default, + query='query_result(count by(job) (count_over_time(up[$__range])))', + regex='/"(.*)-worker"/', + refresh='time', + ), + hiddenCustom( + name, + value, + ):: template.custom( + name=name, + query=value, + current=value, + hide='value', + ), + }, + panel:: { + new( + title, + format= $.format.short, + datasource= $.datasources.default, + ):: graphPanel.new( + title=title, + datasource=datasource, + format=format, + fill=2, + linewidth=2, + legend_alignAsTable=true, + legend_values=true, + legend_max=true, + legend_min=true, + legend_avg=true, + legend_current=true, + legend_sort="current", + legend_sortDesc=true, + ), + transparentText( + title='' + ):: text.new( + title=title, + transparent=true, + ) + }, +} diff --git a/install/dashboard-templates/grpc-dashboard.jsonnet b/install/dashboard-templates/grpc-dashboard.jsonnet new file mode 100644 index 0000000..0cec271 --- /dev/null +++ b/install/dashboard-templates/grpc-dashboard.jsonnet @@ -0,0 +1,21 @@ +local grafana = import 'grafonnet/grafana.libsonnet'; +local panels = import 'panels.jsonnet'; +local common=import 'common.jsonnet'; +local dashboard = grafana.dashboard; +local template = grafana.template; + +dashboard.new( + 'GRPC', + tags=[common.tags.grpc], + time_from='now-1h', + schemaVersion=common.dashboard.schemaVersion, +) +.addTemplate(common.templates.test) +.addPanel(panels.workerNum, { w: 12, h: 6, x: 0, y: 0 }) +.addPanel(panels.virtualUserNum, { w: 12, h: 6, x: 12, y: 0 }) +.addPanel(panels.rpcsPerSecond, { w: 12, h: 8, x: 0, y: 6 }) +.addPanel(panels.rpcLatency, { w: 12, h: 8, x: 12, y: 6 }) +.addPanel(panels.rpcsPerSecondByStatus, { w: 12, h: 8, x: 0, y: 14 }) +.addPanel(panels.percentageFailedRPCs, { w: 12, h: 8, x: 12, y: 14 }) +.addPanel(panels.rpcSentBytes, { w: 12, h: 8, x: 0, y: 22 }) +.addPanel(panels.rpcReceivedBytes, { w: 12, h: 8, x: 12, y: 22 }) \ No newline at end of file diff --git a/install/dashboard-templates/http-dashboard.jsonnet b/install/dashboard-templates/http-dashboard.jsonnet new file mode 100644 index 0000000..4d1505f --- /dev/null +++ b/install/dashboard-templates/http-dashboard.jsonnet @@ -0,0 +1,20 @@ +local grafana = import 'grafonnet/grafana.libsonnet'; +local panels = import 'panels.jsonnet'; +local common=import 'common.jsonnet'; +local dashboard = grafana.dashboard; +local template = grafana.template; + +dashboard.new( + 'HTTP', + tags=[common.tags.http], + time_from='now-1h', + schemaVersion=common.dashboard.schemaVersion, +) +.addTemplate(common.templates.test) +.addPanel(panels.workerNum, { w: 12, h: 6, x: 0, y: 0 }) +.addPanel(panels.virtualUserNum, { w: 12, h: 6, x: 12, y: 0 }) +.addPanel(panels.httpRequestsPerSecond, { w: 12, h: 8, x: 0, y: 6 }) +.addPanel(panels.percentageOf5xxRequests, { w: 12, h: 8, x: 12, y: 6 }) +.addPanel(panels.httpRequestLatency, { w: 12, h: 8, x: 0, y: 14 }) +.addPanel(panels.httpRequestSentBytes, { w: 12, h: 8, x: 0, y: 22 }) +.addPanel(panels.httpRequestReceivedBytes, { w: 12, h: 8, x: 12, y: 22 }) \ No newline at end of file diff --git a/install/dashboard-templates/panels.jsonnet b/install/dashboard-templates/panels.jsonnet new file mode 100644 index 0000000..ac45e51 --- /dev/null +++ b/install/dashboard-templates/panels.jsonnet @@ -0,0 +1,124 @@ +local grafana = import 'grafonnet/grafana.libsonnet'; +local common=import 'common.jsonnet'; +local prometheus = grafana.prometheus; +local graphPanel = grafana.graphPanel; + +{ + ### Worker & VirtualUser pannels + + workerNum:: common.panel.new( + title='Number of workers', + ) + .addTarget(prometheus.target( + 'sum (up{job=~"$testId-worker"})', + legendFormat=' ') + ), + + virtualUserNum:: common.panel.new( + title='Number of virtual users', + ) + .addTarget(prometheus.target( + 'sum by (virtual_user_status) (lotus_virtual_user_count{job=~"$testId-worker"})', + legendFormat='virtual_user_status') + ), + + ### GRPC pannels + + rpcsPerSecond:: common.panel.new( + title='RPCs / second', + ) + .addTarget(prometheus.target( + 'lotus_grpc_client_completed_rpcs_per_second:method{job=~"$testId-worker"}', + legendFormat='{{ grpc_client_method }}') + ), + + rpcsPerSecondByStatus:: common.panel.new( + title='RPCs / seconds grouping by status', + ) + .addTarget(prometheus.target( + 'lotus_grpc_client_completed_rpcs_per_second:status{job=~"$testId-worker"}', + legendFormat='{{ grpc_client_status }}') + ), + + percentageFailedRPCs:: common.panel.new( + title='Percentage of failed RPCs', + format=common.format.percent_0_100, + ) + .addTarget(prometheus.target( + 'lotus_grpc_client_completed_rpcs_failure_percentage:method{job=~"$testId-worker"}', + legendFormat='{{ grpc_client_method }}') + ), + + rpcLatency:: common.panel.new( + title='Latency', + format=common.format.millisecond, + ) + .addTarget(prometheus.target( + 'lotus_grpc_client_roundtrip_latency:method{job=~"$testId-worker"}', + legendFormat='{{ grpc_client_method }}') + ), + + rpcSentBytes:: common.panel.new( + title='Sent Bytes', + format=common.format.bytes, + ) + .addTarget(prometheus.target( + 'lotus_grpc_client_sent_bytes_per_rpc:method{job=~"$testId-worker"}', + legendFormat='{{ grpc_client_method }}') + ), + + rpcReceivedBytes:: common.panel.new( + title='Received Bytes', + format=common.format.bytes, + ) + .addTarget(prometheus.target( + 'lotus_grpc_client_received_bytes_per_rpc:method{job=~"$testId-worker"}', + legendFormat='{{ grpc_client_method }}') + ), + + ### HTTP Pannels + + httpRequestsPerSecond:: common.panel.new( + title='Requests / second', + ) + .addTarget(prometheus.target( + 'lotus_http_client_completed_requests_per_second:host:route:method{job=~"$testId-worker"}', + legendFormat='{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}') + ), + + percentageOf5xxRequests:: common.panel.new( + title='Percentage of 5xx Requests', + format=common.format.percent_0_100, + ) + .addTarget(prometheus.target( + 'lotus_http_client_completed_requests_5xx_percentage:host:route:method{job=~"$testId-worker"}', + legendFormat='{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}') + ), + + httpRequestLatency:: common.panel.new( + title='Latency', + format=common.format.millisecond, + ) + .addTarget(prometheus.target( + 'lotus_http_client_roundtrip_latency:host:route:method{job=~"$testId-worker"}', + legendFormat='{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}') + ), + + httpRequestSentBytes:: common.panel.new( + title='Sent Bytes', + format=common.format.bytes, + ) + .addTarget(prometheus.target( + 'lotus_http_client_sent_bytes:host:route:method{job=~"$testId-worker"}', + legendFormat='{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}') + ), + + httpRequestReceivedBytes:: common.panel.new( + title='Received Bytes', + format=common.format.bytes, + ) + .addTarget(prometheus.target( + 'lotus_http_client_received_bytes:host:route:method{job=~"$testId-worker"}', + legendFormat='{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}') + ), +} \ No newline at end of file diff --git a/install/helm/.helmignore b/install/helm/.helmignore new file mode 100644 index 0000000..f0c1319 --- /dev/null +++ b/install/helm/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/install/helm/Chart.yaml b/install/helm/Chart.yaml new file mode 100644 index 0000000..107d77a --- /dev/null +++ b/install/helm/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for Lotus +name: lotus +version: 0.1.0 diff --git a/install/helm/charts/grafana-1.19.0.tgz b/install/helm/charts/grafana-1.19.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..c2aff733cb7bd807e05b9617aee8036f877d9769 GIT binary patch literal 10659 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 JFiHUM004AQ`j`L! literal 0 HcmV?d00001 diff --git a/install/helm/dashboards/grpc-dashboard.json b/install/helm/dashboards/grpc-dashboard.json new file mode 100644 index 0000000..1a898d6 --- /dev/null +++ b/install/helm/dashboards/grpc-dashboard.json @@ -0,0 +1,756 @@ +{ + "annotations": { + "list": [ ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "id": null, + "links": [ ], + "panels": [ + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum (up{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": " ", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of workers", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (virtual_user_status) (lotus_virtual_user_count{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "virtual_user_status", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of virtual users", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_completed_rpcs_per_second:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "RPCs / second", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 5, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_roundtrip_latency:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_completed_rpcs_per_second:status{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_status }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "RPCs / seconds grouping by status", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_completed_rpcs_failure_percentage:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Percentage of failed RPCs", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_sent_bytes_per_rpc:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Sent Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 9, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_received_bytes_per_rpc:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Received Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "", + "rows": [ ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "grpc" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": { }, + "datasource": "thanos", + "hide": 0, + "includeAll": false, + "label": "TestID", + "multi": false, + "name": "testId", + "options": [ ], + "query": "query_result(count by(job) (count_over_time(up[$__range])))", + "refresh": 2, + "regex": "/\"(.*)-worker\"/", + "sort": 0, + "tagValuesQuery": "", + "tags": [ ], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "GRPC", + "version": 0 +} diff --git a/install/helm/dashboards/http-dashboard.json b/install/helm/dashboards/http-dashboard.json new file mode 100644 index 0000000..300ab5c --- /dev/null +++ b/install/helm/dashboards/http-dashboard.json @@ -0,0 +1,671 @@ +{ + "annotations": { + "list": [ ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "id": null, + "links": [ ], + "panels": [ + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum (up{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": " ", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of workers", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (virtual_user_status) (lotus_virtual_user_count{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "virtual_user_status", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of virtual users", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_completed_requests_per_second:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Requests / second", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 5, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_completed_requests_5xx_percentage:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Percentage of 5xx Requests", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_roundtrip_latency:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_sent_bytes:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Sent Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_received_bytes:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Received Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "", + "rows": [ ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "http" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": { }, + "datasource": "thanos", + "hide": 0, + "includeAll": false, + "label": "TestID", + "multi": false, + "name": "testId", + "options": [ ], + "query": "query_result(count by(job) (count_over_time(up[$__range])))", + "refresh": 2, + "regex": "/\"(.*)-worker\"/", + "sort": 0, + "tagValuesQuery": "", + "tags": [ ], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "HTTP", + "version": 0 +} diff --git a/install/helm/requirements.lock b/install/helm/requirements.lock new file mode 100644 index 0000000..22045ea --- /dev/null +++ b/install/helm/requirements.lock @@ -0,0 +1,6 @@ +dependencies: +- name: grafana + repository: https://kubernetes-charts.storage.googleapis.com + version: 1.19.0 +digest: sha256:b67a4b06d8fcf832a7c1e2b330d7d845c38910d2f60e3ce1e007c8761d10c710 +generated: 2018-11-30T11:08:39.379367813+09:00 diff --git a/install/helm/requirements.yaml b/install/helm/requirements.yaml new file mode 100644 index 0000000..20961b2 --- /dev/null +++ b/install/helm/requirements.yaml @@ -0,0 +1,5 @@ +dependencies: +- name: grafana + version: "1.19.0" + repository: "@stable" + condition: grafana.enabled diff --git a/install/helm/templates/NOTES.txt b/install/helm/templates/NOTES.txt new file mode 100644 index 0000000..e69de29 diff --git a/install/helm/templates/_helpers.tpl b/install/helm/templates/_helpers.tpl new file mode 100644 index 0000000..96352d5 --- /dev/null +++ b/install/helm/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "lotus.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "lotus.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "lotus.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/install/helm/templates/controller-config-configmap.yaml b/install/helm/templates/controller-config-configmap.yaml new file mode 100644 index 0000000..343e063 --- /dev/null +++ b/install/helm/templates/controller-config-configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "lotus.fullname" . }}-controller-config +data: + config.yaml: | +{{ toYaml .Values.lotus.configs | indent 4 }} diff --git a/install/helm/templates/controller-deployment.yaml b/install/helm/templates/controller-deployment.yaml new file mode 100644 index 0000000..00e2175 --- /dev/null +++ b/install/helm/templates/controller-deployment.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ template "lotus.fullname" . }}-controller + labels: + app: {{ template "lotus.fullname" . }}-controller +spec: + replicas: 1 + selector: + matchLabels: + app: {{ template "lotus.fullname" . }}-controller + template: + metadata: + labels: + app: {{ template "lotus.fullname" . }}-controller + spec: +{{- if .Values.lotus.rbac.enabled }} + serviceAccountName: {{ template "lotus.fullname" . }}-controller +{{- end }} + containers: + - name: lotus-controller + image: {{ .Values.lotus.image.repository }}:{{ .Values.lotus.image.tag }} + args: + - controller + - --log-level=debug + - --config-file=/etc/lotus/config.yaml + - --namespace={{ .Release.Namespace }} + - --release={{ .Release.Name }} +{{- if .Values.lotus.rbac.enabled }} + - --prometheus-service-account={{ template "lotus.fullname" . }}-prometheus +{{- end }} + volumeMounts: + - name: config + mountPath: /etc/lotus + readOnly: true + volumes: + - name: config + configMap: + name: {{ template "lotus.fullname" . }}-controller-config diff --git a/install/helm/templates/controller-rbac.yaml b/install/helm/templates/controller-rbac.yaml new file mode 100644 index 0000000..fc17d48 --- /dev/null +++ b/install/helm/templates/controller-rbac.yaml @@ -0,0 +1,65 @@ +{{- if .Values.lotus.rbac.enabled }} +kind: ServiceAccount +apiVersion: v1 +metadata: + name: {{ template "lotus.fullname" . }}-controller +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ template "lotus.fullname" . }}-controller + namespace: {{ .Release.Namespace }} +rules: + - apiGroups: + - "" + - apps + - extensions + resources: + - pods + - deployments + - statefulsets + - services + - configmaps + - secrets + verbs: + - get + - list + - create + - update + - delete + - apiGroups: + - batch + resources: + - jobs + verbs: + - get + - list + - watch + - create + - delete + - apiGroups: + - "lotus.nghialv.com" + resources: + - lotuses + verbs: + - get + - list + - watch + - create + - update + - delete +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ template "lotus.fullname" . }}-controller + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ template "lotus.fullname" . }}-controller +subjects: +- kind: ServiceAccount + name: {{ template "lotus.fullname" . }}-controller + namespace: {{ .Release.Namespace }} +{{- end }} \ No newline at end of file diff --git a/install/helm/templates/crd.yaml b/install/helm/templates/crd.yaml new file mode 100644 index 0000000..c9468dd --- /dev/null +++ b/install/helm/templates/crd.yaml @@ -0,0 +1,44 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: lotuses.lotus.nghialv.com +spec: + group: lotus.nghialv.com + version: v1beta1 + scope: Namespaced + names: + kind: Lotus + plural: lotuses + singular: lotus + categories: + - all + additionalPrinterColumns: + - name: Phase + type: string + description: The current phase of Lotus + JSONPath: .status.phase + - name: WorkerReplicas + type: integer + description: The number of workers launched for this Lotus + JSONPath: .spec.worker.replicas + - name: Age + type: date + JSONPath: .metadata.creationTimestamp + validation: + openAPIV3Schema: + properties: + spec: + required: + - worker + status: + properties: + phase: + type: string + enum: + - "Pending" + - "Preparing" + - "Running" + - "Cleaning" + - "FailureCleaning" + - "Succeeded" + - "Failed" diff --git a/install/helm/templates/grafana-dashboards-configmap.yaml b/install/helm/templates/grafana-dashboards-configmap.yaml new file mode 100644 index 0000000..bfe1c8c --- /dev/null +++ b/install/helm/templates/grafana-dashboards-configmap.yaml @@ -0,0 +1,9 @@ +{{- if .Values.grafana.enabled }} +kind: ConfigMap +metadata: + name: {{ template "lotus.fullname" . }}-grafana-dashboards + labels: + lotus-grafana-dashboard: "true" +data: + {{- (.Files.Glob "dashboards/*.json").AsConfig | nindent 2 }} +{{- end }} \ No newline at end of file diff --git a/install/helm/templates/grafana-datasources-configmap.yaml b/install/helm/templates/grafana-datasources-configmap.yaml new file mode 100644 index 0000000..ad14a41 --- /dev/null +++ b/install/helm/templates/grafana-datasources-configmap.yaml @@ -0,0 +1,17 @@ +{{- if .Values.grafana.enabled }} +kind: ConfigMap +metadata: + name: {{ template "lotus.fullname" . }}-grafana-datasources + labels: + lotus-grafana-datasource: "true" +data: + datasources.yaml: | + apiVersion: 1 + datasources: + - name: thanos + type: prometheus + url: http://{{ template "lotus.fullname" . }}-thanos-query:9090 + access: proxy + basicAuth: false + isDefault: true +{{- end }} diff --git a/install/helm/templates/prometheus-rbac.yaml b/install/helm/templates/prometheus-rbac.yaml new file mode 100644 index 0000000..17c1347 --- /dev/null +++ b/install/helm/templates/prometheus-rbac.yaml @@ -0,0 +1,37 @@ +{{- if .Values.lotus.rbac.enabled }} +kind: ServiceAccount +apiVersion: v1 +metadata: + name: {{ template "lotus.fullname" . }}-prometheus +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ template "lotus.fullname" . }}-prometheus + namespace: {{ .Release.Namespace }} +rules: + - apiGroups: + - "" + resources: + - endpoints + - services + - pods + verbs: + - get + - list + - watch +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ template "lotus.fullname" . }}-prometheus + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ template "lotus.fullname" . }}-prometheus +subjects: +- kind: ServiceAccount + name: {{ template "lotus.fullname" . }}-prometheus + namespace: {{ .Release.Namespace }} +{{- end }} \ No newline at end of file diff --git a/install/helm/values.yaml b/install/helm/values.yaml new file mode 100644 index 0000000..f1f68ca --- /dev/null +++ b/install/helm/values.yaml @@ -0,0 +1,34 @@ +lotus: + image: + repository: nghialv2607/lotus + tag: v0.1.0 + rbac: + enabled: true + configs: + checks: + - name: NoWorker + expr: absent(up) + for: 30s + - name: HasWorkerDown + expr: up == 0 + for: 30s + receivers: + - name: logger + logger: + timeSeriesStorage: + grafanaBaseUrl: "" + +grafana: + enabled: true + replicas: 1 + image: + repository: grafana/grafana + tag: 5.3.4 + adminPassword: admin + sidecar: + dashboards: + enabled: true + label: lotus-grafana-dashboard + datasources: + enabled: true + label: lotus-grafana-datasource diff --git a/install/manifest-generate-values-norbac.yaml b/install/manifest-generate-values-norbac.yaml new file mode 100644 index 0000000..90c8caa --- /dev/null +++ b/install/manifest-generate-values-norbac.yaml @@ -0,0 +1,18 @@ +lotus: + rbac: + enabled: false + # configs: + # checks: FIX-ME---NOT-REQUIRED---SET-GLOBAL-CHECKS + # receivers: FIX-ME---NOT-REQUIRED---SET-SUMMARY-RECEIVERS + # timeSeriesStorage: + # gcs: + # bucket: FIX-ME---SET-BUCKET-TO-STORE-TIMESERIES + # credentials: + # secret: FIX-ME---SET-SECRET-NAME-THAT-CONTAINS-GCS-SERVICE-ACCOUNT + # file: FIX-ME---SET-SERVICE-ACCOUNT-FILENAME + +grafana: + rbac: + create: false + serviceAccount: + create: false diff --git a/install/manifest-generate-values.yaml b/install/manifest-generate-values.yaml new file mode 100644 index 0000000..6ce80b8 --- /dev/null +++ b/install/manifest-generate-values.yaml @@ -0,0 +1,10 @@ +# lotus: +# configs: +# checks: FIX-ME---NOT-REQUIRED---SET-GLOBAL-CHECKS +# receivers: FIX-ME---NOT-REQUIRED---SET-SUMMARY-RECEIVERS +# timeSeriesStorage: +# gcs: +# bucket: FIX-ME---SET-BUCKET-TO-STORE-TIMESERIES +# credentials: +# secret: FIX-ME---SET-SECRET-NAME-THAT-CONTAINS-GCS-SERVICE-ACCOUNT +# file: FIX-ME---SET-SERVICE-ACCOUNT-FILENAME \ No newline at end of file diff --git a/install/manifests-norbac/controller-config-configmap.yaml b/install/manifests-norbac/controller-config-configmap.yaml new file mode 100644 index 0000000..21cda9c --- /dev/null +++ b/install/manifests-norbac/controller-config-configmap.yaml @@ -0,0 +1,21 @@ +--- +# Source: lotus/templates/controller-config-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: lotus-controller-config +data: + config.yaml: | + checks: + - expr: absent(up) + for: 30s + name: NoWorker + - expr: up == 0 + for: 30s + name: HasWorkerDown + grafanaBaseUrl: "" + receivers: + - logger: null + name: logger + timeSeriesStorage: null + diff --git a/install/manifests-norbac/controller-deployment.yaml b/install/manifests-norbac/controller-deployment.yaml new file mode 100644 index 0000000..52512d6 --- /dev/null +++ b/install/manifests-norbac/controller-deployment.yaml @@ -0,0 +1,35 @@ +--- +# Source: lotus/templates/controller-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lotus-controller + labels: + app: lotus-controller +spec: + replicas: 1 + selector: + matchLabels: + app: lotus-controller + template: + metadata: + labels: + app: lotus-controller + spec: + containers: + - name: lotus-controller + image: nghialv2607/lotus:v0.1.0 + args: + - controller + - --log-level=debug + - --config-file=/etc/lotus/config.yaml + - --namespace=default + - --release=lotus + volumeMounts: + - name: config + mountPath: /etc/lotus + readOnly: true + volumes: + - name: config + configMap: + name: lotus-controller-config diff --git a/install/manifests-norbac/crd.yaml b/install/manifests-norbac/crd.yaml new file mode 100644 index 0000000..8a1b497 --- /dev/null +++ b/install/manifests-norbac/crd.yaml @@ -0,0 +1,46 @@ +--- +# Source: lotus/templates/crd.yaml +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: lotuses.lotus.nghialv.com +spec: + group: lotus.nghialv.com + version: v1beta1 + scope: Namespaced + names: + kind: Lotus + plural: lotuses + singular: lotus + categories: + - all + additionalPrinterColumns: + - name: Phase + type: string + description: The current phase of Lotus + JSONPath: .status.phase + - name: WorkerReplicas + type: integer + description: The number of workers launched for this Lotus + JSONPath: .spec.worker.replicas + - name: Age + type: date + JSONPath: .metadata.creationTimestamp + validation: + openAPIV3Schema: + properties: + spec: + required: + - worker + status: + properties: + phase: + type: string + enum: + - "Pending" + - "Preparing" + - "Running" + - "Cleaning" + - "FailureCleaning" + - "Succeeded" + - "Failed" diff --git a/install/manifests-norbac/grafana-configmap-dashboard-provider.yaml b/install/manifests-norbac/grafana-configmap-dashboard-provider.yaml new file mode 100644 index 0000000..9831150 --- /dev/null +++ b/install/manifests-norbac/grafana-configmap-dashboard-provider.yaml @@ -0,0 +1,23 @@ +--- +# Source: lotus/charts/grafana/templates/configmap-dashboard-provider.yaml + +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller + name: lotus-grafana-config-dashboards +data: + provider.yaml: |- + apiVersion: 1 + providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + options: + path: /tmp/dashboards diff --git a/install/manifests-norbac/grafana-configmap.yaml b/install/manifests-norbac/grafana-configmap.yaml new file mode 100644 index 0000000..e623c8e --- /dev/null +++ b/install/manifests-norbac/grafana-configmap.yaml @@ -0,0 +1,24 @@ +--- +# Source: lotus/charts/grafana/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller +data: + grafana.ini: | + [analytics] + check_for_updates = true + [grafana_net] + url = https://grafana.net + [log] + mode = console + [paths] + data = /var/lib/grafana/data + logs = /var/log/grafana + plugins = /var/lib/grafana/plugins + provisioning = /etc/grafana/provisioning diff --git a/install/manifests-norbac/grafana-dashboards-configmap.yaml b/install/manifests-norbac/grafana-dashboards-configmap.yaml new file mode 100644 index 0000000..e71ae0a --- /dev/null +++ b/install/manifests-norbac/grafana-dashboards-configmap.yaml @@ -0,0 +1,1439 @@ +--- +# Source: lotus/templates/grafana-dashboards-configmap.yaml + +kind: ConfigMap +metadata: + name: lotus-grafana-dashboards + labels: + lotus-grafana-dashboard: "true" +data: + grpc-dashboard.json: | + { + "annotations": { + "list": [ ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "id": null, + "links": [ ], + "panels": [ + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum (up{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": " ", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of workers", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (virtual_user_status) (lotus_virtual_user_count{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "virtual_user_status", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of virtual users", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_completed_rpcs_per_second:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "RPCs / second", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 5, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_roundtrip_latency:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_completed_rpcs_per_second:status{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_status }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "RPCs / seconds grouping by status", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_completed_rpcs_failure_percentage:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Percentage of failed RPCs", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_sent_bytes_per_rpc:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Sent Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 9, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_received_bytes_per_rpc:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Received Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "", + "rows": [ ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "grpc" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": { }, + "datasource": "thanos", + "hide": 0, + "includeAll": false, + "label": "TestID", + "multi": false, + "name": "testId", + "options": [ ], + "query": "query_result(count by(job) (count_over_time(up[$__range])))", + "refresh": 2, + "regex": "/\"(.*)-worker\"/", + "sort": 0, + "tagValuesQuery": "", + "tags": [ ], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "GRPC", + "version": 0 + } + http-dashboard.json: | + { + "annotations": { + "list": [ ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "id": null, + "links": [ ], + "panels": [ + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum (up{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": " ", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of workers", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (virtual_user_status) (lotus_virtual_user_count{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "virtual_user_status", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of virtual users", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_completed_requests_per_second:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Requests / second", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 5, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_completed_requests_5xx_percentage:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Percentage of 5xx Requests", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_roundtrip_latency:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_sent_bytes:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Sent Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_received_bytes:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Received Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "", + "rows": [ ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "http" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": { }, + "datasource": "thanos", + "hide": 0, + "includeAll": false, + "label": "TestID", + "multi": false, + "name": "testId", + "options": [ ], + "query": "query_result(count by(job) (count_over_time(up[$__range])))", + "refresh": 2, + "regex": "/\"(.*)-worker\"/", + "sort": 0, + "tagValuesQuery": "", + "tags": [ ], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "HTTP", + "version": 0 + } + \ No newline at end of file diff --git a/install/manifests-norbac/grafana-datasources-configmap.yaml b/install/manifests-norbac/grafana-datasources-configmap.yaml new file mode 100644 index 0000000..cda9bb1 --- /dev/null +++ b/install/manifests-norbac/grafana-datasources-configmap.yaml @@ -0,0 +1,18 @@ +--- +# Source: lotus/templates/grafana-datasources-configmap.yaml + +kind: ConfigMap +metadata: + name: lotus-grafana-datasources + labels: + lotus-grafana-datasource: "true" +data: + datasources.yaml: | + apiVersion: 1 + datasources: + - name: thanos + type: prometheus + url: http://lotus-thanos-query:9090 + access: proxy + basicAuth: false + isDefault: true diff --git a/install/manifests-norbac/grafana-deployment.yaml b/install/manifests-norbac/grafana-deployment.yaml new file mode 100644 index 0000000..c645d51 --- /dev/null +++ b/install/manifests-norbac/grafana-deployment.yaml @@ -0,0 +1,132 @@ +--- +# Source: lotus/charts/grafana/templates/deployment.yaml +apiVersion: apps/v1beta2 +kind: Deployment +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller +spec: + replicas: 1 + selector: + matchLabels: + app: grafana + release: lotus + strategy: + type: RollingUpdate + template: + metadata: + labels: + app: grafana + release: lotus + spec: + serviceAccountName: default + securityContext: + fsGroup: 472 + runAsUser: 472 + + containers: + - name: grafana-sc-dashboard + image: "kiwigrid/k8s-sidecar:0.0.6" + imagePullPolicy: IfNotPresent + env: + - name: LABEL + value: "lotus-grafana-dashboard" + - name: FOLDER + value: "/tmp/dashboards" + resources: + null + + volumeMounts: + - name: sc-dashboard-volume + mountPath: "/tmp/dashboards" + - name: grafana-sc-datasources + image: "kiwigrid/k8s-sidecar:0.0.6" + imagePullPolicy: IfNotPresent + env: + - name: LABEL + value: "lotus-grafana-datasource" + - name: FOLDER + value: "/etc/grafana/provisioning/datasources" + resources: + null + + volumeMounts: + - name: sc-datasources-volume + mountPath: "/etc/grafana/provisioning/datasources" + - name: grafana + image: "grafana/grafana:5.3.4" + imagePullPolicy: IfNotPresent + volumeMounts: + - name: config + mountPath: "/etc/grafana/grafana.ini" + subPath: grafana.ini + - name: ldap + mountPath: "/etc/grafana/ldap.toml" + subPath: ldap.toml + - name: storage + mountPath: "/var/lib/grafana" + subPath: + - name: sc-dashboard-volume + mountPath: "/tmp/dashboards" + - name: sc-dashboard-provider + mountPath: "/etc/grafana/provisioning/dashboards/sc-dashboardproviders.yaml" + subPath: provider.yaml + - name: sc-datasources-volume + mountPath: "/etc/grafana/provisioning/datasources" + ports: + - name: service + containerPort: 80 + protocol: TCP + - name: grafana + containerPort: 3000 + protocol: TCP + env: + - name: GF_SECURITY_ADMIN_USER + valueFrom: + secretKeyRef: + name: lotus-grafana + key: admin-user + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: lotus-grafana + key: admin-password + livenessProbe: + failureThreshold: 10 + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 60 + timeoutSeconds: 30 + + readinessProbe: + httpGet: + path: /api/health + port: 3000 + + resources: + {} + + volumes: + - name: config + configMap: + name: lotus-grafana + - name: ldap + secret: + secretName: lotus-grafana + items: + - key: ldap-toml + path: ldap.toml + - name: storage + emptyDir: {} + - name: sc-dashboard-volume + emptyDir: {} + - name: sc-dashboard-provider + configMap: + name: lotus-grafana-config-dashboards + - name: sc-datasources-volume + emptyDir: {} diff --git a/install/manifests-norbac/grafana-podsecuritypolicy.yaml b/install/manifests-norbac/grafana-podsecuritypolicy.yaml new file mode 100644 index 0000000..61a1a54 --- /dev/null +++ b/install/manifests-norbac/grafana-podsecuritypolicy.yaml @@ -0,0 +1,41 @@ +--- +# Source: lotus/charts/grafana/templates/podsecuritypolicy.yaml + +apiVersion: extensions/v1beta1 +kind: PodSecurityPolicy +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + heritage: Tiller + release: lotus + annotations: + seccomp.security.alpha.kubernetes.io/allowedProfileNames: 'docker/default' + apparmor.security.beta.kubernetes.io/allowedProfileNames: 'runtime/default' + seccomp.security.alpha.kubernetes.io/defaultProfileName: 'docker/default' + apparmor.security.beta.kubernetes.io/defaultProfileName: 'runtime/default' +spec: + privileged: false + allowPrivilegeEscalation: false + requiredDropCapabilities: + - ALL + volumes: + - 'configMap' + - 'emptyDir' + - 'projected' + - 'secret' + - 'downwardAPI' + - 'persistentVolumeClaim' + hostNetwork: false + hostIPC: false + hostPID: false + runAsUser: + rule: 'RunAsAny' + seLinux: + rule: 'RunAsAny' + supplementalGroups: + rule: 'RunAsAny' + fsGroup: + rule: 'RunAsAny' + readOnlyRootFilesystem: false diff --git a/install/manifests-norbac/grafana-secret.yaml b/install/manifests-norbac/grafana-secret.yaml new file mode 100644 index 0000000..66da2bd --- /dev/null +++ b/install/manifests-norbac/grafana-secret.yaml @@ -0,0 +1,16 @@ +--- +# Source: lotus/charts/grafana/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller +type: Opaque +data: + admin-user: "YWRtaW4=" + admin-password: "YWRtaW4=" + ldap-toml: "" diff --git a/install/manifests-norbac/grafana-service.yaml b/install/manifests-norbac/grafana-service.yaml new file mode 100644 index 0000000..023964d --- /dev/null +++ b/install/manifests-norbac/grafana-service.yaml @@ -0,0 +1,22 @@ +--- +# Source: lotus/charts/grafana/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller +spec: + type: ClusterIP + ports: + - name: service + port: 80 + protocol: TCP + targetPort: 3000 + + selector: + app: grafana + release: lotus diff --git a/install/manifests/controller-config-configmap.yaml b/install/manifests/controller-config-configmap.yaml new file mode 100644 index 0000000..21cda9c --- /dev/null +++ b/install/manifests/controller-config-configmap.yaml @@ -0,0 +1,21 @@ +--- +# Source: lotus/templates/controller-config-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: lotus-controller-config +data: + config.yaml: | + checks: + - expr: absent(up) + for: 30s + name: NoWorker + - expr: up == 0 + for: 30s + name: HasWorkerDown + grafanaBaseUrl: "" + receivers: + - logger: null + name: logger + timeSeriesStorage: null + diff --git a/install/manifests/controller-deployment.yaml b/install/manifests/controller-deployment.yaml new file mode 100644 index 0000000..adf58c1 --- /dev/null +++ b/install/manifests/controller-deployment.yaml @@ -0,0 +1,37 @@ +--- +# Source: lotus/templates/controller-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lotus-controller + labels: + app: lotus-controller +spec: + replicas: 1 + selector: + matchLabels: + app: lotus-controller + template: + metadata: + labels: + app: lotus-controller + spec: + serviceAccountName: lotus-controller + containers: + - name: lotus-controller + image: nghialv2607/lotus:v0.1.0 + args: + - controller + - --log-level=debug + - --config-file=/etc/lotus/config.yaml + - --namespace=default + - --release=lotus + - --prometheus-service-account=lotus-prometheus + volumeMounts: + - name: config + mountPath: /etc/lotus + readOnly: true + volumes: + - name: config + configMap: + name: lotus-controller-config diff --git a/install/manifests/controller-rbac.yaml b/install/manifests/controller-rbac.yaml new file mode 100644 index 0000000..161e07d --- /dev/null +++ b/install/manifests/controller-rbac.yaml @@ -0,0 +1,66 @@ +--- +# Source: lotus/templates/controller-rbac.yaml + +kind: ServiceAccount +apiVersion: v1 +metadata: + name: lotus-controller +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: lotus-controller + namespace: default +rules: + - apiGroups: + - "" + - apps + - extensions + resources: + - pods + - deployments + - statefulsets + - services + - configmaps + - secrets + verbs: + - get + - list + - create + - update + - delete + - apiGroups: + - batch + resources: + - jobs + verbs: + - get + - list + - watch + - create + - delete + - apiGroups: + - "lotus.nghialv.com" + resources: + - lotuses + verbs: + - get + - list + - watch + - create + - update + - delete +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: lotus-controller + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: lotus-controller +subjects: +- kind: ServiceAccount + name: lotus-controller + namespace: default \ No newline at end of file diff --git a/install/manifests/crd.yaml b/install/manifests/crd.yaml new file mode 100644 index 0000000..8a1b497 --- /dev/null +++ b/install/manifests/crd.yaml @@ -0,0 +1,46 @@ +--- +# Source: lotus/templates/crd.yaml +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: lotuses.lotus.nghialv.com +spec: + group: lotus.nghialv.com + version: v1beta1 + scope: Namespaced + names: + kind: Lotus + plural: lotuses + singular: lotus + categories: + - all + additionalPrinterColumns: + - name: Phase + type: string + description: The current phase of Lotus + JSONPath: .status.phase + - name: WorkerReplicas + type: integer + description: The number of workers launched for this Lotus + JSONPath: .spec.worker.replicas + - name: Age + type: date + JSONPath: .metadata.creationTimestamp + validation: + openAPIV3Schema: + properties: + spec: + required: + - worker + status: + properties: + phase: + type: string + enum: + - "Pending" + - "Preparing" + - "Running" + - "Cleaning" + - "FailureCleaning" + - "Succeeded" + - "Failed" diff --git a/install/manifests/grafana-clusterrole.yaml b/install/manifests/grafana-clusterrole.yaml new file mode 100644 index 0000000..f87aefc --- /dev/null +++ b/install/manifests/grafana-clusterrole.yaml @@ -0,0 +1,16 @@ +--- +# Source: lotus/charts/grafana/templates/clusterrole.yaml + +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller + name: lotus-grafana-clusterrole +rules: +- apiGroups: [""] # "" indicates the core API group + resources: ["configmaps"] + verbs: ["get", "watch", "list"] diff --git a/install/manifests/grafana-clusterrolebinding.yaml b/install/manifests/grafana-clusterrolebinding.yaml new file mode 100644 index 0000000..2295c26 --- /dev/null +++ b/install/manifests/grafana-clusterrolebinding.yaml @@ -0,0 +1,20 @@ +--- +# Source: lotus/charts/grafana/templates/clusterrolebinding.yaml + +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: lotus-grafana-clusterrolebinding + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller +subjects: + - kind: ServiceAccount + name: lotus-grafana + namespace: default +roleRef: + kind: ClusterRole + name: lotus-grafana-clusterrole + apiGroup: rbac.authorization.k8s.io diff --git a/install/manifests/grafana-configmap-dashboard-provider.yaml b/install/manifests/grafana-configmap-dashboard-provider.yaml new file mode 100644 index 0000000..9831150 --- /dev/null +++ b/install/manifests/grafana-configmap-dashboard-provider.yaml @@ -0,0 +1,23 @@ +--- +# Source: lotus/charts/grafana/templates/configmap-dashboard-provider.yaml + +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller + name: lotus-grafana-config-dashboards +data: + provider.yaml: |- + apiVersion: 1 + providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + options: + path: /tmp/dashboards diff --git a/install/manifests/grafana-configmap.yaml b/install/manifests/grafana-configmap.yaml new file mode 100644 index 0000000..e623c8e --- /dev/null +++ b/install/manifests/grafana-configmap.yaml @@ -0,0 +1,24 @@ +--- +# Source: lotus/charts/grafana/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller +data: + grafana.ini: | + [analytics] + check_for_updates = true + [grafana_net] + url = https://grafana.net + [log] + mode = console + [paths] + data = /var/lib/grafana/data + logs = /var/log/grafana + plugins = /var/lib/grafana/plugins + provisioning = /etc/grafana/provisioning diff --git a/install/manifests/grafana-dashboards-configmap.yaml b/install/manifests/grafana-dashboards-configmap.yaml new file mode 100644 index 0000000..e71ae0a --- /dev/null +++ b/install/manifests/grafana-dashboards-configmap.yaml @@ -0,0 +1,1439 @@ +--- +# Source: lotus/templates/grafana-dashboards-configmap.yaml + +kind: ConfigMap +metadata: + name: lotus-grafana-dashboards + labels: + lotus-grafana-dashboard: "true" +data: + grpc-dashboard.json: | + { + "annotations": { + "list": [ ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "id": null, + "links": [ ], + "panels": [ + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum (up{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": " ", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of workers", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (virtual_user_status) (lotus_virtual_user_count{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "virtual_user_status", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of virtual users", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_completed_rpcs_per_second:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "RPCs / second", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 5, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_roundtrip_latency:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_completed_rpcs_per_second:status{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_status }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "RPCs / seconds grouping by status", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_completed_rpcs_failure_percentage:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Percentage of failed RPCs", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_sent_bytes_per_rpc:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Sent Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 9, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_grpc_client_received_bytes_per_rpc:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ grpc_client_method }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Received Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "", + "rows": [ ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "grpc" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": { }, + "datasource": "thanos", + "hide": 0, + "includeAll": false, + "label": "TestID", + "multi": false, + "name": "testId", + "options": [ ], + "query": "query_result(count by(job) (count_over_time(up[$__range])))", + "refresh": 2, + "regex": "/\"(.*)-worker\"/", + "sort": 0, + "tagValuesQuery": "", + "tags": [ ], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "GRPC", + "version": 0 + } + http-dashboard.json: | + { + "annotations": { + "list": [ ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "id": null, + "links": [ ], + "panels": [ + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum (up{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": " ", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of workers", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (virtual_user_status) (lotus_virtual_user_count{job=~\"$testId-worker\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "virtual_user_status", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Number of virtual users", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_completed_requests_per_second:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Requests / second", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 5, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_completed_requests_5xx_percentage:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Percentage of 5xx Requests", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_roundtrip_latency:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_sent_bytes:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Sent Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "thanos", + "fill": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "seriesOverrides": [ ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "lotus_http_client_received_bytes:host:route:method{job=~\"$testId-worker\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{ http_client_method }}/{{ http_client_host }}{{ http_client_route }}", + "refId": "A" + } + ], + "thresholds": [ ], + "timeFrom": null, + "timeShift": null, + "title": "Received Bytes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ ] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "", + "rows": [ ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "http" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": { }, + "datasource": "thanos", + "hide": 0, + "includeAll": false, + "label": "TestID", + "multi": false, + "name": "testId", + "options": [ ], + "query": "query_result(count by(job) (count_over_time(up[$__range])))", + "refresh": 2, + "regex": "/\"(.*)-worker\"/", + "sort": 0, + "tagValuesQuery": "", + "tags": [ ], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "HTTP", + "version": 0 + } + \ No newline at end of file diff --git a/install/manifests/grafana-datasources-configmap.yaml b/install/manifests/grafana-datasources-configmap.yaml new file mode 100644 index 0000000..cda9bb1 --- /dev/null +++ b/install/manifests/grafana-datasources-configmap.yaml @@ -0,0 +1,18 @@ +--- +# Source: lotus/templates/grafana-datasources-configmap.yaml + +kind: ConfigMap +metadata: + name: lotus-grafana-datasources + labels: + lotus-grafana-datasource: "true" +data: + datasources.yaml: | + apiVersion: 1 + datasources: + - name: thanos + type: prometheus + url: http://lotus-thanos-query:9090 + access: proxy + basicAuth: false + isDefault: true diff --git a/install/manifests/grafana-deployment.yaml b/install/manifests/grafana-deployment.yaml new file mode 100644 index 0000000..28d6c51 --- /dev/null +++ b/install/manifests/grafana-deployment.yaml @@ -0,0 +1,132 @@ +--- +# Source: lotus/charts/grafana/templates/deployment.yaml +apiVersion: apps/v1beta2 +kind: Deployment +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller +spec: + replicas: 1 + selector: + matchLabels: + app: grafana + release: lotus + strategy: + type: RollingUpdate + template: + metadata: + labels: + app: grafana + release: lotus + spec: + serviceAccountName: lotus-grafana + securityContext: + fsGroup: 472 + runAsUser: 472 + + containers: + - name: grafana-sc-dashboard + image: "kiwigrid/k8s-sidecar:0.0.6" + imagePullPolicy: IfNotPresent + env: + - name: LABEL + value: "lotus-grafana-dashboard" + - name: FOLDER + value: "/tmp/dashboards" + resources: + null + + volumeMounts: + - name: sc-dashboard-volume + mountPath: "/tmp/dashboards" + - name: grafana-sc-datasources + image: "kiwigrid/k8s-sidecar:0.0.6" + imagePullPolicy: IfNotPresent + env: + - name: LABEL + value: "lotus-grafana-datasource" + - name: FOLDER + value: "/etc/grafana/provisioning/datasources" + resources: + null + + volumeMounts: + - name: sc-datasources-volume + mountPath: "/etc/grafana/provisioning/datasources" + - name: grafana + image: "grafana/grafana:5.3.4" + imagePullPolicy: IfNotPresent + volumeMounts: + - name: config + mountPath: "/etc/grafana/grafana.ini" + subPath: grafana.ini + - name: ldap + mountPath: "/etc/grafana/ldap.toml" + subPath: ldap.toml + - name: storage + mountPath: "/var/lib/grafana" + subPath: + - name: sc-dashboard-volume + mountPath: "/tmp/dashboards" + - name: sc-dashboard-provider + mountPath: "/etc/grafana/provisioning/dashboards/sc-dashboardproviders.yaml" + subPath: provider.yaml + - name: sc-datasources-volume + mountPath: "/etc/grafana/provisioning/datasources" + ports: + - name: service + containerPort: 80 + protocol: TCP + - name: grafana + containerPort: 3000 + protocol: TCP + env: + - name: GF_SECURITY_ADMIN_USER + valueFrom: + secretKeyRef: + name: lotus-grafana + key: admin-user + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: lotus-grafana + key: admin-password + livenessProbe: + failureThreshold: 10 + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 60 + timeoutSeconds: 30 + + readinessProbe: + httpGet: + path: /api/health + port: 3000 + + resources: + {} + + volumes: + - name: config + configMap: + name: lotus-grafana + - name: ldap + secret: + secretName: lotus-grafana + items: + - key: ldap-toml + path: ldap.toml + - name: storage + emptyDir: {} + - name: sc-dashboard-volume + emptyDir: {} + - name: sc-dashboard-provider + configMap: + name: lotus-grafana-config-dashboards + - name: sc-datasources-volume + emptyDir: {} diff --git a/install/manifests/grafana-podsecuritypolicy.yaml b/install/manifests/grafana-podsecuritypolicy.yaml new file mode 100644 index 0000000..61a1a54 --- /dev/null +++ b/install/manifests/grafana-podsecuritypolicy.yaml @@ -0,0 +1,41 @@ +--- +# Source: lotus/charts/grafana/templates/podsecuritypolicy.yaml + +apiVersion: extensions/v1beta1 +kind: PodSecurityPolicy +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + heritage: Tiller + release: lotus + annotations: + seccomp.security.alpha.kubernetes.io/allowedProfileNames: 'docker/default' + apparmor.security.beta.kubernetes.io/allowedProfileNames: 'runtime/default' + seccomp.security.alpha.kubernetes.io/defaultProfileName: 'docker/default' + apparmor.security.beta.kubernetes.io/defaultProfileName: 'runtime/default' +spec: + privileged: false + allowPrivilegeEscalation: false + requiredDropCapabilities: + - ALL + volumes: + - 'configMap' + - 'emptyDir' + - 'projected' + - 'secret' + - 'downwardAPI' + - 'persistentVolumeClaim' + hostNetwork: false + hostIPC: false + hostPID: false + runAsUser: + rule: 'RunAsAny' + seLinux: + rule: 'RunAsAny' + supplementalGroups: + rule: 'RunAsAny' + fsGroup: + rule: 'RunAsAny' + readOnlyRootFilesystem: false diff --git a/install/manifests/grafana-role.yaml b/install/manifests/grafana-role.yaml new file mode 100644 index 0000000..34f1668 --- /dev/null +++ b/install/manifests/grafana-role.yaml @@ -0,0 +1,17 @@ +--- +# Source: lotus/charts/grafana/templates/role.yaml + +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: Role +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + heritage: Tiller + release: lotus +rules: +- apiGroups: ['extensions'] + resources: ['podsecuritypolicies'] + verbs: ['use'] + resourceNames: [lotus-grafana] diff --git a/install/manifests/grafana-rolebinding.yaml b/install/manifests/grafana-rolebinding.yaml new file mode 100644 index 0000000..2368a1a --- /dev/null +++ b/install/manifests/grafana-rolebinding.yaml @@ -0,0 +1,18 @@ +--- +# Source: lotus/charts/grafana/templates/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: RoleBinding +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + heritage: Tiller + release: lotus +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: lotus-grafana +subjects: +- kind: ServiceAccount + name: lotus-grafana \ No newline at end of file diff --git a/install/manifests/grafana-secret.yaml b/install/manifests/grafana-secret.yaml new file mode 100644 index 0000000..66da2bd --- /dev/null +++ b/install/manifests/grafana-secret.yaml @@ -0,0 +1,16 @@ +--- +# Source: lotus/charts/grafana/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller +type: Opaque +data: + admin-user: "YWRtaW4=" + admin-password: "YWRtaW4=" + ldap-toml: "" diff --git a/install/manifests/grafana-service.yaml b/install/manifests/grafana-service.yaml new file mode 100644 index 0000000..023964d --- /dev/null +++ b/install/manifests/grafana-service.yaml @@ -0,0 +1,22 @@ +--- +# Source: lotus/charts/grafana/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: lotus-grafana + labels: + app: grafana + chart: grafana-1.19.0 + release: lotus + heritage: Tiller +spec: + type: ClusterIP + ports: + - name: service + port: 80 + protocol: TCP + targetPort: 3000 + + selector: + app: grafana + release: lotus diff --git a/install/manifests/grafana-serviceaccount.yaml b/install/manifests/grafana-serviceaccount.yaml new file mode 100644 index 0000000..914acc7 --- /dev/null +++ b/install/manifests/grafana-serviceaccount.yaml @@ -0,0 +1,12 @@ +--- +# Source: lotus/charts/grafana/templates/serviceaccount.yaml + +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app: grafana + chart: grafana-1.19.0 + heritage: Tiller + release: lotus + name: lotus-grafana diff --git a/install/manifests/prometheus-rbac.yaml b/install/manifests/prometheus-rbac.yaml new file mode 100644 index 0000000..11a71a4 --- /dev/null +++ b/install/manifests/prometheus-rbac.yaml @@ -0,0 +1,38 @@ +--- +# Source: lotus/templates/prometheus-rbac.yaml + +kind: ServiceAccount +apiVersion: v1 +metadata: + name: lotus-prometheus +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: lotus-prometheus + namespace: default +rules: + - apiGroups: + - "" + resources: + - endpoints + - services + - pods + verbs: + - get + - list + - watch +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: lotus-prometheus + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: lotus-prometheus +subjects: +- kind: ServiceAccount + name: lotus-prometheus + namespace: default \ No newline at end of file diff --git a/jsonnetfile.json b/jsonnetfile.json new file mode 100644 index 0000000..86000e9 --- /dev/null +++ b/jsonnetfile.json @@ -0,0 +1,14 @@ +{ + "dependencies": [ + { + "name": "grafonnet", + "source": { + "git": { + "remote": "https://github.com/grafana/grafonnet-lib", + "subdir": "grafonnet" + } + }, + "version": "master" + } + ] +} diff --git a/jsonnetfile.lock.json b/jsonnetfile.lock.json new file mode 100644 index 0000000..8a99886 --- /dev/null +++ b/jsonnetfile.lock.json @@ -0,0 +1,14 @@ +{ + "dependencies": [ + { + "name": "grafonnet", + "source": { + "git": { + "remote": "https://github.com/grafana/grafonnet-lib", + "subdir": "grafonnet" + } + }, + "version": "eea8b5ba6b8883cf2df5a17c39a42c4b57c0d63e" + } + ] +} \ No newline at end of file diff --git a/libsonnet/grafonnet/alert_condition.libsonnet b/libsonnet/grafonnet/alert_condition.libsonnet new file mode 100644 index 0000000..d70a1d3 --- /dev/null +++ b/libsonnet/grafonnet/alert_condition.libsonnet @@ -0,0 +1,44 @@ +{ + /** + * Returns a new condition of alert of graph panel. + * Currently the only condition type that exists is a Query condition + * that allows to specify a query letter, time range and an aggregation function. + * + * @param evaluatorParams Value of threshold + * @param evaluatorType Type of threshold + * @param operatorType Operator between conditions + * @param queryRefId The letter defines what query to execute from the Metrics tab + * @param queryTimeStart Begging of time range + * @param queryTimeEnd End of time range + * @param reducerParams Params of an aggregation function + * @param reducerType Name of an aggregation function + * @return A json that represents a condition of alert + */ + new( + evaluatorParams=[], + evaluatorType='gt', + operatorType='and', + queryRefId='A', + queryTimeEnd='now', + queryTimeStart='5m', + reducerParams=[], + reducerType='avg', + ):: + { + evaluator: { + params: if std.type(evaluatorParams) == 'array' then evaluatorParams else [evaluatorParams], + type: evaluatorType, + }, + operator: { + type: operatorType, + }, + query: { + params: [queryRefId, queryTimeStart, queryTimeEnd], + }, + reducer: { + params: if std.type(reducerParams) == 'array' then reducerParams else [reducerParams], + type: reducerType, + }, + type: 'query', + }, +} diff --git a/libsonnet/grafonnet/annotation.libsonnet b/libsonnet/grafonnet/annotation.libsonnet new file mode 100644 index 0000000..653211e --- /dev/null +++ b/libsonnet/grafonnet/annotation.libsonnet @@ -0,0 +1,35 @@ +{ + default:: + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + datasource( + name, + datasource, + expr=null, + enable=true, + hide=false, + iconColor='rgba(255, 96, 96, 1)', + tags=[], + type='tags', + builtIn=null, + ):: + { + datasource: datasource, + enable: enable, + [if expr != null then 'expr']: expr, + hide: hide, + iconColor: iconColor, + name: name, + showIn: 0, + tags: tags, + type: type, + [if builtIn != null then 'builtIn']: builtIn, + }, +} diff --git a/libsonnet/grafonnet/cloudwatch.libsonnet b/libsonnet/grafonnet/cloudwatch.libsonnet new file mode 100644 index 0000000..6312eda --- /dev/null +++ b/libsonnet/grafonnet/cloudwatch.libsonnet @@ -0,0 +1,39 @@ +{ + /** + * Return a CloudWatch Target + * + * @param region + * @param namespace + * @param metric + * @param datasource + * @param statistic + * @param alias + * @param highResolution + * @param period + * @param dimensions + + * @return Panel target + */ + + target( + region, + namespace, + metric, + datasource=null, + statistic='Average', + alias=null, + highResolution=false, + period='1m', + dimensions={} + ):: { + region: region, + namespace: namespace, + metricName: metric, + [if datasource != null then 'datasource']: datasource, + statistics: [statistic], + [if alias != null then 'alias']: alias, + highResolution: highResolution, + period: period, + dimensions: dimensions, + }, +} diff --git a/libsonnet/grafonnet/dashboard.libsonnet b/libsonnet/grafonnet/dashboard.libsonnet new file mode 100644 index 0000000..f07b548 --- /dev/null +++ b/libsonnet/grafonnet/dashboard.libsonnet @@ -0,0 +1,121 @@ +local timepickerlib = import 'timepicker.libsonnet'; + +{ + new( + title, + editable=false, + style='dark', + tags=[], + time_from='now-6h', + time_to='now', + timezone='browser', + refresh='', + timepicker=timepickerlib.new(), + graphTooltip='default', + hideControls=false, + schemaVersion=14, + uid='', + description=null, + ):: { + local it = self, + _annotations:: [], + [if uid != '' then 'uid']: uid, + editable: editable, + [if description != null then 'description']: description, + gnetId: null, + graphTooltip: + if graphTooltip == 'shared_tooltip' then 2 + else if graphTooltip == 'shared_crosshair' then 1 + else if graphTooltip == 'default' then 0 + else graphTooltip, + hideControls: hideControls, + id: null, + links: [], + panels:: [], + refresh: refresh, + rows: [], + schemaVersion: schemaVersion, + style: style, + tags: tags, + time: { + from: time_from, + to: time_to, + }, + timezone: timezone, + timepicker: timepicker, + title: title, + version: 0, + addAnnotation(annotation):: self { + _annotations+:: [annotation], + }, + addTemplate(t):: self { + templates+: [t], + }, + templates:: [], + annotations: { list: it._annotations }, + templating: { list: it.templates }, + _nextPanel:: 2, + addRow(row):: + self { + // automatically number panels in added rows. + // https://github.com/kausalco/public/blob/master/klumps/grafana.libsonnet + local n = std.length(row.panels), + local nextPanel = super._nextPanel, + local panels = std.makeArray(n, function(i) + row.panels[i] { id: nextPanel + i }), + + _nextPanel: nextPanel + n, + rows+: [row { panels: panels }], + }, + addPanels(newpanels):: + self { + // automatically number panels in added rows. + // https://github.com/kausalco/public/blob/master/klumps/grafana.libsonnet + local n = std.foldl(function(numOfPanels, p) + (if 'panels' in p then + numOfPanels + 1 + std.length(p.panels) + else + numOfPanels + 1), newpanels, 0), + local nextPanel = super._nextPanel, + local _panels = std.makeArray( + std.length(newpanels), function(i) + newpanels[i] { + id: nextPanel + ( + if i == 0 then + 0 + else + if 'panels' in _panels[i - 1] then + (_panels[i - 1].id - nextPanel) + 1 + std.length(_panels[i - 1].panels) + else + (_panels[i - 1].id - nextPanel) + 1 + + ), + [if 'panels' in newpanels[i] then 'panels']: std.makeArray( + std.length(newpanels[i].panels), function(j) + newpanels[i].panels[j] { + id: 1 + j + + nextPanel + ( + if i == 0 then + 0 + else + if 'panels' in _panels[i - 1] then + (_panels[i - 1].id - nextPanel) + 1 + std.length(_panels[i - 1].panels) + else + (_panels[i - 1].id - nextPanel) + 1 + + ), + } + ), + } + ), + + _nextPanel: nextPanel + n, + panels+::: _panels, + }, + addPanel(panel, gridPos):: self + self.addPanels([panel { gridPos: gridPos }]), + addRows(rows):: std.foldl(function(d, row) d.addRow(row), rows, self), + addLink(link):: self { + links+: [link], + }, + }, +} diff --git a/libsonnet/grafonnet/elasticsearch.libsonnet b/libsonnet/grafonnet/elasticsearch.libsonnet new file mode 100644 index 0000000..1f87762 --- /dev/null +++ b/libsonnet/grafonnet/elasticsearch.libsonnet @@ -0,0 +1,38 @@ +{ + target( + query, + id=null, + datasource=null, + metrics=[{ + field: "value", + id: null, + type: "percentiles", + settings: { + percents: [ + "90", + ], + }, + }], + bucketAggs=[{ + field: "timestamp", + id: null, + type: "date_histogram", + settings: { + interval: "1s", + min_doc_count: 0, + trimEdges: 0 + }, + }], + timeField, + alias=null, + ):: { + [if datasource != null then 'datasource']: datasource, + query: query, + id: id, + timeField: timeField, + bucketAggs: bucketAggs, + metrics: metrics, + alias: alias + // TODO: generate bucket ids + } +} diff --git a/libsonnet/grafonnet/grafana.libsonnet b/libsonnet/grafonnet/grafana.libsonnet new file mode 100644 index 0000000..5b7e7d3 --- /dev/null +++ b/libsonnet/grafonnet/grafana.libsonnet @@ -0,0 +1,19 @@ +{ + dashboard:: import 'dashboard.libsonnet', + template:: import 'template.libsonnet', + text:: import 'text.libsonnet', + timepicker:: import 'timepicker.libsonnet', + row:: import 'row.libsonnet', + link:: import 'link.libsonnet', + annotation:: import 'annotation.libsonnet', + graphPanel:: import 'graph_panel.libsonnet', + tablePanel:: import 'table_panel.libsonnet', + singlestat:: import 'singlestat.libsonnet', + influxdb:: import 'influxdb.libsonnet', + prometheus:: import 'prometheus.libsonnet', + sql:: import 'sql.libsonnet', + graphite:: import 'graphite.libsonnet', + alertCondition:: import 'alert_condition.libsonnet', + cloudwatch:: import 'cloudwatch.libsonnet', + elasticsearch:: import 'elasticsearch.libsonnet', +} diff --git a/libsonnet/grafonnet/graph_panel.libsonnet b/libsonnet/grafonnet/graph_panel.libsonnet new file mode 100644 index 0000000..6df2ddf --- /dev/null +++ b/libsonnet/grafonnet/graph_panel.libsonnet @@ -0,0 +1,232 @@ +{ + /** + * Returns a new graph panel that can be added in a row. + * It requires the graph panel plugin in grafana, which is built-in. + * + * @param title The title of the graph panel. + * @param span Width of the panel + * @param datasource Datasource + * @param fill Fill, integer from 0 to 10 + * @param linewidth Line Width, integer from 0 to 10 + * @param decimals Override automatic decimal precision for legend and tooltip. If null, not added to the json output. + * @param min_span Min span + * @param format Unit of the Y axes + * @param formatY1 Unit of the first Y axe + * @param formatY2 Unit of the second Y axe + * @param min Min of the Y axes + * @param max Max of the Y axes + * @param x_axis_mode X axis mode, one of [time, series, histogram] + * @param x_axis_values Chosen value of series, one of [avg, min, max, total, count] + * @param lines Display lines, boolean + * @param points Display points, boolean + * @param pointradius Radius of the points, allowed values are 0.5 or [1 ... 10] with step 1 + * @param bars Display bars, boolean + * @param dashes Display line as dashes + * @param stack Stack values + * @param repeat Variable used to repeat the graph panel + * @param legend_show Show legend + * @param legend_values Show values in legend + * @param legend_min Show min in legend + * @param legend_max Show max in legend + * @param legend_current Show current in legend + * @param legend_total Show total in legend + * @param legend_avg Show average in legend + * @param legend_alignAsTable Show legend as table + * @param legend_rightSide Show legend to the right + * @param legend_sort Sort order of legend + * @param legend_sortDesc Sort legend descending + * @param aliasColors Define color mappings for graphs + * @param valueType Type of tooltip value + * @param thresholds Configuration of graph thresholds + * @param logBase1Y Value of logarithm base of the first Y axe + * @param logBase2Y Value of logarithm base of the second Y axe + * @param transparent Boolean (default: false) If set to true the panel will be transparent + * @return A json that represents a graph panel + */ + new( + title, + span=null, + fill=1, + linewidth=1, + decimals=null, + description=null, + min_span=null, + format='short', + formatY1=null, + formatY2=null, + min=null, + max=null, + x_axis_mode='time', + x_axis_values='total', + lines=true, + datasource=null, + points=false, + pointradius=5, + bars=false, + height=null, + nullPointMode='null', + dashes=false, + stack=false, + repeat=null, + repeatDirection=null, + sort=0, + show_xaxis=true, + legend_show=true, + legend_values=false, + legend_min=false, + legend_max=false, + legend_current=false, + legend_total=false, + legend_avg=false, + legend_alignAsTable=false, + legend_rightSide=false, + legend_hideEmpty=null, + legend_hideZero=null, + legend_sort=null, + legend_sortDesc=null, + aliasColors={}, + thresholds=[], + logBase1Y=1, + logBase2Y=1, + transparent=false, + value_type='individual' + ):: { + title: title, + [if span != null then 'span']: span, + [if min_span != null then 'minSpan']: min_span, + type: 'graph', + datasource: datasource, + targets: [ + ], + [if description != null then 'description']: description, + [if height != null then 'height']: height, + renderer: 'flot', + yaxes: [ + self.yaxe(if formatY1 != null then formatY1 else format, min, max, decimals=decimals, logBase=logBase1Y), + self.yaxe(if formatY2 != null then formatY2 else format, min, max, decimals=decimals, logBase=logBase2Y), + ], + xaxis: { + show: show_xaxis, + mode: x_axis_mode, + name: null, + values: if x_axis_mode == 'series' then [x_axis_values] else [], + buckets: null, + }, + lines: lines, + fill: fill, + linewidth: linewidth, + dashes: dashes, + dashLength: 10, + spaceLength: 10, + points: points, + pointradius: pointradius, + bars: bars, + stack: stack, + percentage: false, + legend: { + show: legend_show, + values: legend_values, + min: legend_min, + max: legend_max, + current: legend_current, + total: legend_total, + alignAsTable: legend_alignAsTable, + rightSide: legend_rightSide, + avg: legend_avg, + [if legend_hideEmpty != null then 'hideEmpty']: legend_hideEmpty, + [if legend_hideZero != null then 'hideZero']: legend_hideZero, + [if legend_sort != null then 'sort']: legend_sort, + [if legend_sortDesc != null then 'sortDesc']: legend_sortDesc, + }, + nullPointMode: nullPointMode, + steppedLine: false, + tooltip: { + value_type: value_type, + shared: true, + sort: if sort == 'decreasing' then 2 else if sort == 'increasing' then 1 else sort, + }, + timeFrom: null, + timeShift: null, + [if transparent == true then 'transparent']: transparent, + aliasColors: aliasColors, + repeat: repeat, + [if repeatDirection != null then 'repeatDirection']: repeatDirection, + seriesOverrides: [], + thresholds: thresholds, + links: [], + yaxe( + format='short', + min=null, + max=null, + label=null, + show=true, + logBase=1, + decimals=null, + ):: { + label: label, + show: show, + logBase: logBase, + min: min, + max: max, + format: format, + [if decimals != null then 'decimals']: decimals, + }, + _nextTarget:: 0, + addTarget(target):: self { + // automatically ref id in added targets. + // https://github.com/kausalco/public/blob/master/klumps/grafana.libsonnet + local nextTarget = super._nextTarget, + _nextTarget: nextTarget + 1, + targets+: [target { refId: std.char(std.codepoint('A') + nextTarget) }], + }, + addTargets(targets):: std.foldl(function(p, t) p.addTarget(t), targets, self), + _nextSeriesOverride:: 0, + addSeriesOverride(override):: self { + local nextOverride = super._nextSerieOverride, + _nextSeriesOverride: nextOverride + 1, + seriesOverrides+: [override], + }, + resetYaxes():: self { + yaxes: [], + _nextYaxis:: 0, + }, + _nextYaxis:: 0, + addYaxis( + format='short', + min=null, + max=null, + label=null, + show=true, + logBase=1, + decimals=null, + ):: self { + local nextYaxis = super._nextYaxis, + _nextYaxis: nextYaxis + 1, + yaxes+: [self.yaxe(format, min, max, label, show, logBase, decimals)], + }, + addAlert( + name, + executionErrorState='alerting', + frequency='60s', + handler=1, + noDataState='no_data', + notifications=[], + ):: self { + local it = self, + _conditions:: [], + alert: { + name: name, + conditions: it._conditions, + executionErrorState: executionErrorState, + frequency: frequency, + handler: handler, + noDataState: noDataState, + notifications: notifications, + }, + addCondition(condition):: self { + _conditions+: [condition], + }, + addConditions(conditions):: std.foldl(function(p, c) p.addCondition(c), conditions, it), + }, + }, +} diff --git a/libsonnet/grafonnet/graphite.libsonnet b/libsonnet/grafonnet/graphite.libsonnet new file mode 100644 index 0000000..15e20eb --- /dev/null +++ b/libsonnet/grafonnet/graphite.libsonnet @@ -0,0 +1,27 @@ +{ + /** + * Return an Graphite Target + * + * @param target Graphite Query. Nested queries are possible by adding the query reference (refId). + * @param targetFull Expanding the @target. Used in nested queries. + * @param hide Disable query on graph. + * @param textEditor Enable raw query mode. + * @param datasource Datasource. + + * @return Panel target + */ + target( + target, + targetFull=null, + hide=false, + textEditor=false, + datasource=null, + ):: { + target: target, + hide: hide, + textEditor: textEditor, + + [if targetFull != null then 'targetFull']: targetFull, + [if datasource != null then 'datasource']: datasource, + }, +} diff --git a/libsonnet/grafonnet/influxdb.libsonnet b/libsonnet/grafonnet/influxdb.libsonnet new file mode 100644 index 0000000..8e0451f --- /dev/null +++ b/libsonnet/grafonnet/influxdb.libsonnet @@ -0,0 +1,27 @@ +{ + /** + * Return an InfluxDB Target + * + * @param query Raw InfluxQL statement + * @param alias Alias By pattern + * @param datasource Datasource + * @param rawQuery En/Disable raw query mode + * @param resultFormat Format results as 'Time series' or 'Table' + + * @return Panel target + */ + target( + query, + alias=null, + datasource=null, + rawQuery=true, + resultFormat='time_series', + ):: { + query: query, + rawQuery: rawQuery, + resultFormat: resultFormat, + + [if alias != null then 'alias']: alias, + [if datasource != null then 'datasource']: datasource, + }, +} diff --git a/libsonnet/grafonnet/link.libsonnet b/libsonnet/grafonnet/link.libsonnet new file mode 100644 index 0000000..97db18a --- /dev/null +++ b/libsonnet/grafonnet/link.libsonnet @@ -0,0 +1,24 @@ +{ + dashboards( + title, + tags, + asDropdown=true, + includeVars=false, + keepTime=false, + icon='external link', + url='', + targetBlank=false, + type='dashboards', + ):: + { + asDropdown: asDropdown, + icon: icon, + includeVars: includeVars, + keepTime: keepTime, + tags: tags, + title: title, + type: type, + url: url, + targetBlank: targetBlank, + }, +} diff --git a/libsonnet/grafonnet/prometheus.libsonnet b/libsonnet/grafonnet/prometheus.libsonnet new file mode 100644 index 0000000..a15645d --- /dev/null +++ b/libsonnet/grafonnet/prometheus.libsonnet @@ -0,0 +1,19 @@ +{ + target( + expr, + format='time_series', + intervalFactor=2, + legendFormat='', + datasource=null, + interval=null, + instant=null, + ):: { + [if datasource != null then 'datasource']: datasource, + expr: expr, + format: format, + intervalFactor: intervalFactor, + legendFormat: legendFormat, + [if interval != null then 'interval']: interval, + [if instant != null then 'instant']: instant, + }, +} diff --git a/libsonnet/grafonnet/row.libsonnet b/libsonnet/grafonnet/row.libsonnet new file mode 100644 index 0000000..f5eb6c7 --- /dev/null +++ b/libsonnet/grafonnet/row.libsonnet @@ -0,0 +1,32 @@ +{ + new( + title='Dashboard Row', + height=null, + collapse=false, + repeat=null, + showTitle=null, + titleSize='h6' + ):: { + collapse: collapse, + collapsed: collapse, + [if height != null then 'height']: height, + panels: [], + repeat: repeat, + repeatIteration: null, + repeatRowId: null, + showTitle: + if showTitle != null then + showTitle + else + title != 'Dashboard Row', + title: title, + type: 'row', + titleSize: titleSize, + addPanels(panels):: self { + panels+: panels, + }, + addPanel(panel, gridPos={}):: self { + panels+: [panel { gridPos: gridPos }], + }, + }, +} diff --git a/libsonnet/grafonnet/singlestat.libsonnet b/libsonnet/grafonnet/singlestat.libsonnet new file mode 100644 index 0000000..23d838f --- /dev/null +++ b/libsonnet/grafonnet/singlestat.libsonnet @@ -0,0 +1,127 @@ +{ + new( + title, + format='none', + description='', + interval=null, + height=null, + datasource=null, + span=null, + min_span=null, + decimals=null, + valueName='avg', + valueFontSize='80%', + prefixFontSize='50%', + postfixFontSize='50%', + mappingType=1, + repeat=null, + repeatDirection=null, + prefix='', + postfix='', + colors=[ + '#299c46', + 'rgba(237, 129, 40, 0.89)', + '#d44a3a', + ], + colorBackground=false, + colorValue=false, + thresholds='', + valueMaps=[ + { + value: 'null', + op: '=', + text: 'N/A', + }, + ], + rangeMaps=[ + { + from: 'null', + to: 'null', + text: 'N/A', + }, + ], + transparent=null, + sparklineFillColor='rgba(31, 118, 189, 0.18)', + sparklineFull=false, + sparklineLineColor='rgb(31, 120, 193)', + sparklineShow=false, + gaugeShow=false, + gaugeMinValue=0, + gaugeMaxValue=100, + gaugeThresholdMarkers=true, + gaugeThresholdLabels=false, + ):: + { + [if height != null then 'height']: height, + [if description != '' then 'description']: description, + [if repeat != null then 'repeat']: repeat, + [if repeatDirection != null then 'repeatDirection']: repeatDirection, + [if transparent != null then 'transparent']: transparent, + [if min_span != null then 'minSpan']: min_span, + title: title, + [if span != null then 'span']: span, + type: 'singlestat', + datasource: datasource, + targets: [ + ], + links: [], + [if decimals != null then 'decimals']: decimals, + maxDataPoints: 100, + interval: interval, + cacheTimeout: null, + format: format, + prefix: prefix, + postfix: postfix, + nullText: null, + valueMaps: valueMaps, + mappingTypes: [ + { + name: 'value to text', + value: 1, + }, + { + name: 'range to text', + value: 2, + }, + ], + rangeMaps: rangeMaps, + mappingType: + if mappingType == 'value' + then + 1 + else if mappingType == 'range' + then + 2 + else + mappingType, + nullPointMode: 'connected', + valueName: valueName, + prefixFontSize: prefixFontSize, + valueFontSize: valueFontSize, + postfixFontSize: postfixFontSize, + thresholds: thresholds, + colorBackground: colorBackground, + colorValue: colorValue, + colors: colors, + gauge: { + show: gaugeShow, + minValue: gaugeMinValue, + maxValue: gaugeMaxValue, + thresholdMarkers: gaugeThresholdMarkers, + thresholdLabels: gaugeThresholdLabels, + }, + sparkline: { + fillColor: sparklineFillColor, + full: sparklineFull, + lineColor: sparklineLineColor, + show: sparklineShow, + }, + tableColumn: '', + _nextTarget:: 0, + addTarget(target):: self { + local nextTarget = super._nextTarget, + _nextTarget: nextTarget + 1, + targets+: [target { refId: std.char(std.codepoint('A') + nextTarget) }], + }, + }, +} diff --git a/libsonnet/grafonnet/sql.libsonnet b/libsonnet/grafonnet/sql.libsonnet new file mode 100644 index 0000000..22229f6 --- /dev/null +++ b/libsonnet/grafonnet/sql.libsonnet @@ -0,0 +1,11 @@ +{ + target( + rawSql, + datasource=null, + format='time_series', + ):: { + [if datasource != null then 'datasource']: datasource, + format: format, + rawSql: rawSql, + }, +} diff --git a/libsonnet/grafonnet/table_panel.libsonnet b/libsonnet/grafonnet/table_panel.libsonnet new file mode 100644 index 0000000..be32211 --- /dev/null +++ b/libsonnet/grafonnet/table_panel.libsonnet @@ -0,0 +1,41 @@ +{ + /** + * Returns a new table panel that can be added in a row. + * It requires the table panel plugin in grafana, which is built-in. + * + * @param title The title of the graph panel. + * @param span Width of the panel + * @param description Description of the panel + * @param datasource Datasource + * @param min_span Min span + * @param styles Styles for the panel + * @return A json that represents a table panel + */ + new( + title, + description=null, + span=null, + min_span=null, + datasource=null, + styles=[], + ):: { + type: 'table', + title: title, + [if span != null then 'span']: span, + [if min_span != null then 'minSpan']: min_span, + datasource: datasource, + targets: [ + ], + styles: styles, + [if description != null then 'description']: description, + transform: 'table', + _nextTarget:: 0, + addTarget(target):: self { + // automatically ref id in added targets. + // https://github.com/kausalco/public/blob/master/klumps/grafana.libsonnet + local nextTarget = super._nextTarget, + _nextTarget: nextTarget + 1, + targets+: [target { refId: std.char(std.codepoint('A') + nextTarget) }], + }, + }, +} diff --git a/libsonnet/grafonnet/template.libsonnet b/libsonnet/grafonnet/template.libsonnet new file mode 100644 index 0000000..afc22cd --- /dev/null +++ b/libsonnet/grafonnet/template.libsonnet @@ -0,0 +1,131 @@ +{ + new( + name, + datasource, + query, + label=null, + allValues=null, + tagValuesQuery='', + current=null, + hide='', + regex='', + refresh='never', + includeAll=false, + multi=false, + sort=0, + ):: + { + allValue: allValues, + current: $.current(current), + datasource: datasource, + includeAll: includeAll, + hide: $.hide(hide), + label: label, + multi: multi, + name: name, + options: [], + query: query, + refresh: $.refresh(refresh), + regex: regex, + sort: sort, + tagValuesQuery: tagValuesQuery, + tags: [], + tagsQuery: '', + type: 'query', + useTags: false, + }, + interval( + name, + query, + current, + hide='', + label=null, + auto_count=300, + auto_min='10s', + ):: + { + current: $.current(current), + hide: if hide == '' then 0 else if hide == 'label' then 1 else 2, + label: label, + name: name, + query: std.join(',', std.filter($.filterAuto, std.split(query, ','))), + refresh: 2, + type: 'interval', + auto: std.count(std.split(query, ','), 'auto') > 0, + auto_count: auto_count, + auto_min: auto_min, + }, + hide(hide):: + if hide == '' then 0 else if hide == 'label' then 1 else 2, + current(current):: { + [if current != null then 'text']: current, + [if current != null then 'value']: if current == 'auto' then + '$__auto_interval' + else if current == 'all' then + '$__all' + else + current, + }, + datasource( + name, + query, + current, + hide='', + label=null, + regex='', + refresh='load', + ):: { + current: $.current(current), + hide: $.hide(hide), + label: label, + name: name, + options: [], + query: query, + refresh: $.refresh(refresh), + regex: regex, + type: 'datasource', + }, + refresh(refresh):: if refresh == 'never' + then + 0 + else if refresh == 'load' + then + 1 + else if refresh == 'time' + then + 2 + else + refresh, + filterAuto(str):: str != 'auto', + custom( + name, + query, + current, + refresh='never', + label='', + valuelabels={}, + hide='', + ):: + { + allValue: null, + current: { + value: current, + text: if current in valuelabels then valuelabels[current] else current, + }, + options: std.map( + function(i) + { + text: if i in valuelabels then valuelabels[i] else i, + value: i, + }, std.split(query, ',') + ), + hide: $.hide(hide), + includeAll: false, + label: label, + refresh: $.refresh(refresh), + multi: false, + name: name, + query: query, + type: 'custom', + }, +} diff --git a/libsonnet/grafonnet/text.libsonnet b/libsonnet/grafonnet/text.libsonnet new file mode 100644 index 0000000..757d693 --- /dev/null +++ b/libsonnet/grafonnet/text.libsonnet @@ -0,0 +1,17 @@ +{ + new( + title='', + span=null, + mode='markdown', + content='', + transparent=null, + ):: + { + [if transparent != null then 'transparent']: transparent, + title: title, + [if span != null then 'span']: span, + type: 'text', + mode: mode, + content: content, + }, +} diff --git a/libsonnet/grafonnet/timepicker.libsonnet b/libsonnet/grafonnet/timepicker.libsonnet new file mode 100644 index 0000000..0d51d2e --- /dev/null +++ b/libsonnet/grafonnet/timepicker.libsonnet @@ -0,0 +1,30 @@ +{ + new( + refresh_intervals=[ + '5s', + '10s', + '30s', + '1m', + '5m', + '15m', + '30m', + '1h', + '2h', + '1d', + ], + time_options=[ + '5m', + '15m', + '1h', + '6h', + '12h', + '24h', + '2d', + '7d', + '30d', + ], + ):: { + refresh_intervals: refresh_intervals, + time_options: time_options, + }, +} diff --git a/pgv_proto_library.bzl b/pgv_proto_library.bzl new file mode 100644 index 0000000..8cecedb --- /dev/null +++ b/pgv_proto_library.bzl @@ -0,0 +1,24 @@ +load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library") +load("@io_bazel_rules_go//proto:compiler.bzl", "go_proto_compiler") + +def pgv_go_proto_library(name, proto = None, deps = [], **kwargs): + go_proto_compiler( + name = "pgv_plugin_go", + suffix = ".pb.validate.go", + valid_archive = False, + plugin = "@com_lyft_protoc_gen_validate//:protoc-gen-validate", + options = ["lang=go"], + ) + + go_proto_library( + name = name, + proto = proto, + deps = ["@com_lyft_protoc_gen_validate//validate:go_default_library"] + deps, + compilers = [ + "@io_bazel_rules_go//proto:go_proto", + "pgv_plugin_go", + ], + visibility = ["//visibility:public"], + **kwargs + ) + diff --git a/pkg/app/example/cmd/helloworld/BUILD.bazel b/pkg/app/example/cmd/helloworld/BUILD.bazel new file mode 100644 index 0000000..3e07de3 --- /dev/null +++ b/pkg/app/example/cmd/helloworld/BUILD.bazel @@ -0,0 +1,17 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["helloworld.go"], + importpath = "github.com/nghialv/lotus/pkg/app/example/cmd/helloworld", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/example/helloworld:go_default_library", + "//pkg/cli:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@org_golang_google_grpc//:go_default_library", + "@org_golang_google_grpc//codes:go_default_library", + "@org_golang_google_grpc//status:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/example/cmd/helloworld/helloworld.go b/pkg/app/example/cmd/helloworld/helloworld.go new file mode 100644 index 0000000..855224b --- /dev/null +++ b/pkg/app/example/cmd/helloworld/helloworld.go @@ -0,0 +1,122 @@ +package helloworld + +import ( + "context" + "fmt" + "math/rand" + "net" + "net/http" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + helloworldproto "github.com/nghialv/lotus/pkg/app/example/helloworld" + "github.com/nghialv/lotus/pkg/cli" +) + +type server struct { + grpcPort int + httpPort int +} + +func NewCommand() *cobra.Command { + s := &server{ + grpcPort: 8080, + httpPort: 9090, + } + cmd := &cobra.Command{ + Use: "helloworld", + Short: "Start running helloworld server", + RunE: cli.WithContext(s.run), + } + cmd.Flags().IntVar(&s.grpcPort, "grpc-port", s.grpcPort, "Port number used to expose grpc server") + cmd.Flags().IntVar(&s.httpPort, "http-port", s.httpPort, "Port number used to expose http server") + return cmd +} + +func (s *server) run(ctx context.Context, logger *zap.Logger) error { + // Start a grpc server to handle grpc calls defined in helloworld.proto + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.grpcPort)) + if err != nil { + logger.Error("failed to listen on grpc port", zap.Int("port", s.grpcPort)) + return err + } + grpcServer := grpc.NewServer() + defer grpcServer.GracefulStop() + helloworldproto.RegisterGreeterServer(grpcServer, &api{}) + go func() { + if err := grpcServer.Serve(lis); err != nil { + logger.Error("failed to server", zap.Error(err)) + } + }() + + // Start a http server to handle http requests + mux := http.NewServeMux() + mux.HandleFunc("/", httpHandler) + mux.HandleFunc("/account", httpHandler) + mux.HandleFunc("/account/profile", httpHandler) + mux.HandleFunc("/api/message", httpHandler) + hs := &http.Server{ + Addr: fmt.Sprintf(":%d", s.httpPort), + Handler: mux, + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + hs.Shutdown(ctx) + }() + go func() { + err := hs.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + logger.Error("failed to run metrics server", zap.Error(err)) + } + }() + + // Wait until we got a signal + <-ctx.Done() + return nil +} + +func httpHandler(w http.ResponseWriter, req *http.Request) { + codes := []int{200, 200, 200, 200, 200, 200, 200, 200, 404, 500} + code := codes[rand.Int()%len(codes)] + response := fmt.Sprintf("reponse code: %d", code) + + d := time.Duration((rand.Int()%5)+1) * 50 * time.Millisecond + time.Sleep(d) + + if code == 200 { + fmt.Fprintln(w, response) + return + } + http.Error(w, response, code) +} + +type api struct{} + +func (a *api) SayHello(ctx context.Context, in *helloworldproto.HelloRequest) (*helloworldproto.HelloResponse, error) { + time.Sleep(time.Duration(rand.Float64()) * time.Second) + + if rand.Int()%200 == 0 { + return nil, status.Error(codes.Internal, "api: internal error") + } + return &helloworldproto.HelloResponse{ + Message: "Hello " + in.Name, + }, nil +} + +func (a *api) GetProfile(ctx context.Context, in *helloworldproto.ProfileRequest) (*helloworldproto.ProfileResponse, error) { + time.Sleep(time.Duration(rand.Float64()) * time.Second) + + if rand.Int()%100 == 0 { + return nil, status.Error(codes.Internal, "api: internal error") + } + return &helloworldproto.ProfileResponse{ + Name: in.Name, + Homepage: fmt.Sprintf("http://helloworld/%s", in.Name), + }, nil +} diff --git a/pkg/app/example/cmd/simplegrpc/BUILD.bazel b/pkg/app/example/cmd/simplegrpc/BUILD.bazel new file mode 100644 index 0000000..a69902f --- /dev/null +++ b/pkg/app/example/cmd/simplegrpc/BUILD.bazel @@ -0,0 +1,17 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["scenario.go"], + importpath = "github.com/nghialv/lotus/pkg/app/example/cmd/simplegrpc", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/example/helloworld:go_default_library", + "//pkg/cli:go_default_library", + "//pkg/metrics:go_default_library", + "//pkg/metrics/grpcmetrics:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@org_golang_google_grpc//:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/example/cmd/simplegrpc/scenario.go b/pkg/app/example/cmd/simplegrpc/scenario.go new file mode 100644 index 0000000..17d6502 --- /dev/null +++ b/pkg/app/example/cmd/simplegrpc/scenario.go @@ -0,0 +1,68 @@ +package simplegrpc + +import ( + "context" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "google.golang.org/grpc" + + helloworldproto "github.com/nghialv/lotus/pkg/app/example/helloworld" + "github.com/nghialv/lotus/pkg/cli" + "github.com/nghialv/lotus/pkg/metrics" + "github.com/nghialv/lotus/pkg/metrics/grpcmetrics" +) + +type scenario struct { + helloWorldGRPCAddress string +} + +func NewCommand() *cobra.Command { + s := &scenario{} + cmd := &cobra.Command{ + Use: "simple-grpc-scenario", + Short: "Start running simple grpc scenario", + RunE: cli.WithContext(s.run), + } + cmd.Flags().StringVar(&s.helloWorldGRPCAddress, "helloworld-grpc-address", s.helloWorldGRPCAddress, "The grpc address to helloword service") + cmd.MarkFlagRequired("helloworld-grpc-address") + return cmd +} + +func (s *scenario) run(ctx context.Context, logger *zap.Logger) error { + // Expose a metrics server + ms, err := metrics.NewServer( + 8081, + metrics.WithLogger(logger.Sugar()), + ) + if err != nil { + logger.Error("failed to create metrics server", zap.Error(err)) + return err + } + defer ms.Stop() + go ms.Run() + + // Just send a grpc rpc to helloworld server + conn, err := grpc.Dial( + s.helloWorldGRPCAddress, + grpc.WithStatsHandler(&grpcmetrics.ClientHandler{}), + grpc.WithInsecure(), + ) + if err != nil { + logger.Error("failed to connect to helloworld server", zap.Error(err)) + return err + } + defer conn.Close() + + client := helloworldproto.NewGreeterClient(conn) + _, err = client.SayHello(ctx, &helloworldproto.HelloRequest{ + Name: "lotus", + }) + if err != nil { + logger.Error("failed to say hello to server", zap.Error(err)) + } + + // Wait until we got a signal + <-ctx.Done() + return nil +} diff --git a/pkg/app/example/cmd/simplehttp/BUILD.bazel b/pkg/app/example/cmd/simplehttp/BUILD.bazel new file mode 100644 index 0000000..fa4b157 --- /dev/null +++ b/pkg/app/example/cmd/simplehttp/BUILD.bazel @@ -0,0 +1,16 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["scenario.go"], + importpath = "github.com/nghialv/lotus/pkg/app/example/cmd/simplehttp", + visibility = ["//visibility:public"], + deps = [ + "//pkg/cli:go_default_library", + "//pkg/metrics:go_default_library", + "//pkg/metrics/httpmetrics:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@org_golang_x_net//context/ctxhttp:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/example/cmd/simplehttp/scenario.go b/pkg/app/example/cmd/simplehttp/scenario.go new file mode 100644 index 0000000..a76140d --- /dev/null +++ b/pkg/app/example/cmd/simplehttp/scenario.go @@ -0,0 +1,57 @@ +package simplehttp + +import ( + "context" + "net/http" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "golang.org/x/net/context/ctxhttp" + + "github.com/nghialv/lotus/pkg/cli" + "github.com/nghialv/lotus/pkg/metrics" + "github.com/nghialv/lotus/pkg/metrics/httpmetrics" +) + +type scenario struct { +} + +func NewCommand() *cobra.Command { + s := &scenario{} + cmd := &cobra.Command{ + Use: "simple-http-scenario", + Short: "Start running simple http scenario", + RunE: cli.WithContext(s.run), + } + return cmd +} + +func (s *scenario) run(ctx context.Context, logger *zap.Logger) error { + // Expose a metrics server + ms, err := metrics.NewServer( + 8081, + metrics.WithLogger(logger.Sugar()), + ) + if err != nil { + logger.Error("failed to create metrics server", zap.Error(err)) + return err + } + defer ms.Stop() + go ms.Run() + + // Just send a http request + client := &http.Client{ + Transport: &httpmetrics.Transport{ + UsePathAsRoute: true, + }, + } + resp, err := ctxhttp.Get(ctx, client, "http://httpbin.org/user-agent") + if err != nil { + return err + } + resp.Body.Close() + + // Wait until we got a signal + <-ctx.Done() + return nil +} diff --git a/pkg/app/example/cmd/threesteps/BUILD.bazel b/pkg/app/example/cmd/threesteps/BUILD.bazel new file mode 100644 index 0000000..d0c5ff8 --- /dev/null +++ b/pkg/app/example/cmd/threesteps/BUILD.bazel @@ -0,0 +1,19 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["scenario.go"], + importpath = "github.com/nghialv/lotus/pkg/app/example/cmd/threesteps", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/example/helloworld:go_default_library", + "//pkg/cli:go_default_library", + "//pkg/metrics:go_default_library", + "//pkg/metrics/grpcmetrics:go_default_library", + "//pkg/metrics/httpmetrics:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@org_golang_google_grpc//:go_default_library", + "@org_golang_x_net//context/ctxhttp:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/example/cmd/threesteps/scenario.go b/pkg/app/example/cmd/threesteps/scenario.go new file mode 100644 index 0000000..a080629 --- /dev/null +++ b/pkg/app/example/cmd/threesteps/scenario.go @@ -0,0 +1,162 @@ +package threesteps + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "math/rand" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "golang.org/x/net/context/ctxhttp" + "google.golang.org/grpc" + + helloworldproto "github.com/nghialv/lotus/pkg/app/example/helloworld" + "github.com/nghialv/lotus/pkg/cli" + "github.com/nghialv/lotus/pkg/metrics" + "github.com/nghialv/lotus/pkg/metrics/grpcmetrics" + "github.com/nghialv/lotus/pkg/metrics/httpmetrics" +) + +var steps = []string{ + "preparer", + "worker", + "cleaner", +} + +type scenario struct { + step string + duration time.Duration + helloWorldGRPCAddress string + helloWorldHTTPAddress string + + httpClient *http.Client + grpcClient helloworldproto.GreeterClient +} + +func NewCommand() *cobra.Command { + s := &scenario{ + step: steps[0], + duration: 10 * time.Second, + } + cmd := &cobra.Command{ + Use: "three-steps-scenario", + Short: "Start running three steps scenario", + RunE: cli.WithContext(s.run), + } + cmd.Flags().StringVar(&s.step, "step", s.step, "The step will be run") + cmd.Flags().DurationVar(&s.duration, "duration", s.duration, "How long this step should be run (Only valid preparer or cleaner)") + cmd.Flags().StringVar(&s.helloWorldGRPCAddress, "helloworld-grpc-address", s.helloWorldGRPCAddress, "The grpc address to helloword service") + cmd.MarkFlagRequired("helloworld-grpc-address") + cmd.Flags().StringVar(&s.helloWorldHTTPAddress, "helloworld-http-address", s.helloWorldHTTPAddress, "The http address to helloword service") + cmd.MarkFlagRequired("helloworld-http-address") + return cmd +} + +func (s *scenario) run(ctx context.Context, logger *zap.Logger) error { + // Expose a metrics server + ms, err := metrics.NewServer( + 8081, + metrics.WithLogger(logger.Sugar()), + ) + if err != nil { + logger.Error("failed to create metrics server", zap.Error(err)) + return err + } + defer ms.Stop() + go ms.Run() + + // For worker step + if s.step == "worker" { + return s.runWorker(ctx, logger) + } + + // For preparer or cleaner step we just wait + select { + case <-time.After(s.duration): + return nil + case <-ctx.Done(): + return nil + } +} + +func (s *scenario) runWorker(ctx context.Context, logger *zap.Logger) error { + s.httpClient = &http.Client{ + Transport: &httpmetrics.Transport{ + UsePathAsRoute: true, + }, + } + conn, err := grpc.Dial( + s.helloWorldGRPCAddress, + grpc.WithStatsHandler(&grpcmetrics.ClientHandler{}), + grpc.WithInsecure(), + ) + if err != nil { + logger.Error("failed to connect to helloworl server", zap.Error(err)) + return err + } + defer conn.Close() + s.grpcClient = helloworldproto.NewGreeterClient(conn) + + // Periodically send the requests + ticker := time.NewTicker(200 * time.Millisecond) + for { + select { + case <-ticker.C: + x := rand.Int() % 5 + if x == 0 { + s.sendHTTP(ctx, logger) + break + } + s.sendGRPC(ctx, x+1, logger) + case <-ctx.Done(): + return nil + } + } +} + +func (s *scenario) sendHTTP(ctx context.Context, logger *zap.Logger) { + paths := []string{ + "/", + "/account", + "/account/profile", + "/api/message", + } + x := rand.Int() % len(paths) + address := fmt.Sprintf("%s%s", s.helloWorldHTTPAddress, paths[x]) + + //ctx, _ = metrics.ContextWithRoute(ctx, "foo") + resp, err := ctxhttp.Get(ctx, s.httpClient, address) + if err != nil { + return + } + io.Copy(ioutil.Discard, resp.Body) + resp.Body.Close() + + b := strings.NewReader(`{"key":"value"}`) + resp, err = ctxhttp.Post(ctx, s.httpClient, address, "application/json", b) + if err != nil { + return + } + //io.Copy(ioutil.Discard, resp.Body) + resp.Body.Close() +} + +func (s *scenario) sendGRPC(ctx context.Context, rpcs int, logger *zap.Logger) { + for i := 0; i < rpcs; i++ { + name := fmt.Sprintf("name-%d", i) + var err error + if i%2 == 0 { + _, err = s.grpcClient.SayHello(ctx, &helloworldproto.HelloRequest{Name: name}) + } else { + _, err = s.grpcClient.GetProfile(ctx, &helloworldproto.ProfileRequest{Name: name}) + } + if err != nil { + logger.Warn("failed to send grpc rpc", zap.Error(err)) + } + } +} diff --git a/pkg/app/example/cmd/virtualuser/BUILD.bazel b/pkg/app/example/cmd/virtualuser/BUILD.bazel new file mode 100644 index 0000000..ab99b0c --- /dev/null +++ b/pkg/app/example/cmd/virtualuser/BUILD.bazel @@ -0,0 +1,23 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "scenario.go", + "user.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/example/cmd/virtualuser", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/example/helloworld:go_default_library", + "//pkg/cli:go_default_library", + "//pkg/metrics:go_default_library", + "//pkg/metrics/grpcmetrics:go_default_library", + "//pkg/virtualuser:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@org_golang_google_grpc//:go_default_library", + "@org_golang_google_grpc//codes:go_default_library", + "@org_golang_google_grpc//status:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/example/cmd/virtualuser/scenario.go b/pkg/app/example/cmd/virtualuser/scenario.go new file mode 100644 index 0000000..caf300f --- /dev/null +++ b/pkg/app/example/cmd/virtualuser/scenario.go @@ -0,0 +1,65 @@ +package virtualuser + +import ( + "context" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/nghialv/lotus/pkg/cli" + "github.com/nghialv/lotus/pkg/metrics" + "github.com/nghialv/lotus/pkg/virtualuser" +) + +type scenario struct { + hatchRate int + numVirtualUsers int + helloWorldGRPCAddress string +} + +func NewCommand() *cobra.Command { + s := &scenario{ + hatchRate: 1, + numVirtualUsers: 10, + } + cmd := &cobra.Command{ + Use: "virtual-user-scenario", + Short: "Start running virtual user scenario", + RunE: cli.WithContext(s.run), + } + cmd.Flags().IntVar(&s.hatchRate, "hatch-rate", s.hatchRate, "How many virtual users should be spwawn per second") + cmd.Flags().IntVar(&s.numVirtualUsers, "num-virtual-users", s.numVirtualUsers, "How many virtual users should be spawn in total") + cmd.Flags().StringVar(&s.helloWorldGRPCAddress, "helloworld-grpc-address", s.helloWorldGRPCAddress, "The grpc address to helloword service") + cmd.MarkFlagRequired("helloworld-grpc-address") + return cmd +} + +func (s *scenario) run(ctx context.Context, logger *zap.Logger) error { + // Expose a metrics server + ms, err := metrics.NewServer( + 8081, + metrics.WithLogger(logger.Sugar()), + ) + if err != nil { + logger.Error("failed to create metrics server", zap.Error(err)) + return err + } + defer ms.Stop() + go ms.Run() + + // Start a group of virtual users + group := virtualuser.NewGroup( + s.numVirtualUsers, + s.hatchRate, + func() (virtualuser.VirtualUser, error) { + return newUser(s.helloWorldGRPCAddress, logger) + }, + ) + defer group.Stop(time.Second) + go group.Run(ctx) + + // Wait until we got a signal + <-ctx.Done() + return nil +} diff --git a/pkg/app/example/cmd/virtualuser/user.go b/pkg/app/example/cmd/virtualuser/user.go new file mode 100644 index 0000000..bef64a8 --- /dev/null +++ b/pkg/app/example/cmd/virtualuser/user.go @@ -0,0 +1,65 @@ +package virtualuser + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + helloworldproto "github.com/nghialv/lotus/pkg/app/example/helloworld" + "github.com/nghialv/lotus/pkg/metrics/grpcmetrics" +) + +type User struct { + conn *grpc.ClientConn + client helloworldproto.GreeterClient + logger *zap.Logger +} + +func newUser(address string, logger *zap.Logger) (*User, error) { + conn, err := grpc.Dial( + address, + grpc.WithStatsHandler(&grpcmetrics.ClientHandler{}), + grpc.WithInsecure(), + ) + if err != nil { + return nil, err + } + return &User{ + conn: conn, + client: helloworldproto.NewGreeterClient(conn), + logger: logger, + }, nil +} + +func (u *User) Run(ctx context.Context) error { + defer u.conn.Close() + var index int = 0 + ticker := time.NewTicker(500 * time.Millisecond) + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + u.sendGRPC(ctx, index) + index++ + } + } +} + +func (u *User) sendGRPC(ctx context.Context, index int) error { + name := fmt.Sprintf("name-%d", index) + _, err := u.client.SayHello(ctx, &helloworldproto.HelloRequest{Name: name}) + if err != nil { + code := status.Code(err) + if code != codes.Canceled { + u.logger.Warn("failed to send grpc rpc", zap.Error(err)) + return err + } + } + return nil +} diff --git a/pkg/app/example/helloworld/BUILD.bazel b/pkg/app/example/helloworld/BUILD.bazel new file mode 100644 index 0000000..94057d8 --- /dev/null +++ b/pkg/app/example/helloworld/BUILD.bazel @@ -0,0 +1,23 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") +load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library") + +proto_library( + name = "helloworld_proto", + srcs = ["helloworld.proto"], + visibility = ["//visibility:public"], +) + +go_proto_library( + name = "helloworld_go_proto", + compilers = ["@io_bazel_rules_go//proto:go_grpc"], + importpath = "github.com/nghialv/lotus/pkg/app/example/helloworld", + proto = ":helloworld_proto", + visibility = ["//visibility:public"], +) + +go_library( + name = "go_default_library", + embed = [":helloworld_go_proto"], + importpath = "github.com/nghialv/lotus/pkg/app/example/helloworld", + visibility = ["//visibility:public"], +) diff --git a/pkg/app/example/helloworld/helloworld.proto b/pkg/app/example/helloworld/helloworld.proto new file mode 100644 index 0000000..8e130ae --- /dev/null +++ b/pkg/app/example/helloworld/helloworld.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package pkg.example.helloworld; +option go_package = "helloworld"; + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloResponse) {} + rpc GetProfile (ProfileRequest) returns (ProfileResponse) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloResponse { + string message = 1; +} + +message ProfileRequest { + string name = 1; +} + +message ProfileResponse{ + string name = 1; + string homepage = 2; +} diff --git a/pkg/app/lotus/apis/lotus/BUILD.bazel b/pkg/app/lotus/apis/lotus/BUILD.bazel new file mode 100644 index 0000000..6dc85d3 --- /dev/null +++ b/pkg/app/lotus/apis/lotus/BUILD.bazel @@ -0,0 +1,8 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["register.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus", + visibility = ["//visibility:public"], +) diff --git a/pkg/app/lotus/apis/lotus/register.go b/pkg/app/lotus/apis/lotus/register.go new file mode 100644 index 0000000..047719f --- /dev/null +++ b/pkg/app/lotus/apis/lotus/register.go @@ -0,0 +1,5 @@ +package lotus + +const ( + GroupName = "lotus.nghialv.com" +) diff --git a/pkg/app/lotus/apis/lotus/v1beta1/BUILD.bazel b/pkg/app/lotus/apis/lotus/v1beta1/BUILD.bazel new file mode 100644 index 0000000..55064c7 --- /dev/null +++ b/pkg/app/lotus/apis/lotus/v1beta1/BUILD.bazel @@ -0,0 +1,20 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "doc.go", + "register.go", + "types.go", + "zz_generated.deepcopy.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus:go_default_library", + "@io_k8s_api//core/v1:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library", + ], +) diff --git a/pkg/app/lotus/apis/lotus/v1beta1/doc.go b/pkg/app/lotus/apis/lotus/v1beta1/doc.go new file mode 100644 index 0000000..b9fc395 --- /dev/null +++ b/pkg/app/lotus/apis/lotus/v1beta1/doc.go @@ -0,0 +1,5 @@ +// +k8s:deepcopy-gen=package +// +groupName=lotus.nghialv.com + +// Package v1beta1 is the v1beta1 version of the API. +package v1beta1 diff --git a/pkg/app/lotus/apis/lotus/v1beta1/register.go b/pkg/app/lotus/apis/lotus/v1beta1/register.go new file mode 100644 index 0000000..5b543ce --- /dev/null +++ b/pkg/app/lotus/apis/lotus/v1beta1/register.go @@ -0,0 +1,37 @@ +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + lotus "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus" +) + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: lotus.GroupName, Version: "v1beta1"} + +// Kind takes an unqualified kind and returns back a Group qualified GroupKind +func Kind(kind string) schema.GroupKind { + return SchemeGroupVersion.WithKind(kind).GroupKind() +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +// Adds the list of known types to Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &Lotus{}, + &LotusList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/pkg/app/lotus/apis/lotus/v1beta1/types.go b/pkg/app/lotus/apis/lotus/v1beta1/types.go new file mode 100644 index 0000000..241244d --- /dev/null +++ b/pkg/app/lotus/apis/lotus/v1beta1/types.go @@ -0,0 +1,85 @@ +package v1beta1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type Lotus struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec LotusSpec `json:"spec"` + Status LotusStatus `json:"status"` +} + +type LotusSpec struct { + TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished"` + CheckIntervalSeconds *int32 `json:"checkIntervalSeconds"` + CheckInitialDelaySeconds *int32 `json:"checkInitialDelaySeconds"` + + Preparer *LotusSpecPreparer `json:"preparer"` + Worker *LotusSpecWorker `json:"worker"` + Cleaner *LotusSpecCleaner `json:"cleaner"` + Checks []LotusCheck `json:"checks"` +} + +type LotusSpecWorker struct { + RunTime string `json:"runTime"` + Replicas *int32 `json:"replicas"` + MetricsPort *int32 `json:"metricsPort"` + Containers []corev1.Container `json:"containers"` + Volumes []corev1.Volume `json:"volumes"` +} + +type LotusSpecPreparer struct { + Containers []corev1.Container `json:"containers"` + Volumes []corev1.Volume `json:"volumes"` +} + +type LotusSpecCleaner struct { + Containers []corev1.Container `json:"containers"` + Volumes []corev1.Volume `json:"volumes"` +} + +type LotusCheck struct { + Name string `json:"name"` + Expr string `json:"expr"` + For string `json:"for"` + DataSource string `json:"dataSource"` +} + +type LotusPhase string + +const ( + LotusInit LotusPhase = "" + LotusPending = "Pending" + LotusPreparing = "Preparing" + LotusRunning = "Running" + LotusCleaning = "Cleaning" + LotusFailureCleaning = "FailureCleaning" + LotusSucceeded = "Succeeded" + LotusFailed = "Failed" +) + +type LotusStatus struct { + PreparerStartTime *metav1.Time `json:"preparerStartTime"` + PreparerCompletionTime *metav1.Time `json:"preparerCompletionTime"` + WorkerStartTime *metav1.Time `json:"workerStartTime"` + WorkerCompletionTime *metav1.Time `json:"workerCompletionTime"` + CleanerStartTime *metav1.Time `json:"cleanerStartTime"` + CleanerCompletionTime *metav1.Time `json:"cleanerCompletionTime"` + Phase LotusPhase `json:"phase"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type LotusList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []Lotus `json:"items"` +} diff --git a/pkg/app/lotus/apis/lotus/v1beta1/zz_generated.deepcopy.go b/pkg/app/lotus/apis/lotus/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 0000000..3620944 --- /dev/null +++ b/pkg/app/lotus/apis/lotus/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,284 @@ +// +build !ignore_autogenerated + +/* + +Generated by using code-generator + +*/ + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1 "k8s.io/api/core/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Lotus) DeepCopyInto(out *Lotus) { + *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 Lotus. +func (in *Lotus) DeepCopy() *Lotus { + if in == nil { + return nil + } + out := new(Lotus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Lotus) 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 *LotusCheck) DeepCopyInto(out *LotusCheck) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LotusCheck. +func (in *LotusCheck) DeepCopy() *LotusCheck { + if in == nil { + return nil + } + out := new(LotusCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LotusList) DeepCopyInto(out *LotusList) { + *out = *in + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Lotus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LotusList. +func (in *LotusList) DeepCopy() *LotusList { + if in == nil { + return nil + } + out := new(LotusList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LotusList) 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 *LotusSpec) DeepCopyInto(out *LotusSpec) { + *out = *in + if in.TTLSecondsAfterFinished != nil { + in, out := &in.TTLSecondsAfterFinished, &out.TTLSecondsAfterFinished + *out = new(int32) + **out = **in + } + if in.CheckIntervalSeconds != nil { + in, out := &in.CheckIntervalSeconds, &out.CheckIntervalSeconds + *out = new(int32) + **out = **in + } + if in.CheckInitialDelaySeconds != nil { + in, out := &in.CheckInitialDelaySeconds, &out.CheckInitialDelaySeconds + *out = new(int32) + **out = **in + } + if in.Preparer != nil { + in, out := &in.Preparer, &out.Preparer + *out = new(LotusSpecPreparer) + (*in).DeepCopyInto(*out) + } + if in.Worker != nil { + in, out := &in.Worker, &out.Worker + *out = new(LotusSpecWorker) + (*in).DeepCopyInto(*out) + } + if in.Cleaner != nil { + in, out := &in.Cleaner, &out.Cleaner + *out = new(LotusSpecCleaner) + (*in).DeepCopyInto(*out) + } + if in.Checks != nil { + in, out := &in.Checks, &out.Checks + *out = make([]LotusCheck, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LotusSpec. +func (in *LotusSpec) DeepCopy() *LotusSpec { + if in == nil { + return nil + } + out := new(LotusSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LotusSpecCleaner) DeepCopyInto(out *LotusSpecCleaner) { + *out = *in + if in.Containers != nil { + in, out := &in.Containers, &out.Containers + *out = make([]v1.Container, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = make([]v1.Volume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LotusSpecCleaner. +func (in *LotusSpecCleaner) DeepCopy() *LotusSpecCleaner { + if in == nil { + return nil + } + out := new(LotusSpecCleaner) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LotusSpecPreparer) DeepCopyInto(out *LotusSpecPreparer) { + *out = *in + if in.Containers != nil { + in, out := &in.Containers, &out.Containers + *out = make([]v1.Container, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = make([]v1.Volume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LotusSpecPreparer. +func (in *LotusSpecPreparer) DeepCopy() *LotusSpecPreparer { + if in == nil { + return nil + } + out := new(LotusSpecPreparer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LotusSpecWorker) DeepCopyInto(out *LotusSpecWorker) { + *out = *in + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + if in.MetricsPort != nil { + in, out := &in.MetricsPort, &out.MetricsPort + *out = new(int32) + **out = **in + } + if in.Containers != nil { + in, out := &in.Containers, &out.Containers + *out = make([]v1.Container, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = make([]v1.Volume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LotusSpecWorker. +func (in *LotusSpecWorker) DeepCopy() *LotusSpecWorker { + if in == nil { + return nil + } + out := new(LotusSpecWorker) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LotusStatus) DeepCopyInto(out *LotusStatus) { + *out = *in + if in.PreparerStartTime != nil { + in, out := &in.PreparerStartTime, &out.PreparerStartTime + *out = (*in).DeepCopy() + } + if in.PreparerCompletionTime != nil { + in, out := &in.PreparerCompletionTime, &out.PreparerCompletionTime + *out = (*in).DeepCopy() + } + if in.WorkerStartTime != nil { + in, out := &in.WorkerStartTime, &out.WorkerStartTime + *out = (*in).DeepCopy() + } + if in.WorkerCompletionTime != nil { + in, out := &in.WorkerCompletionTime, &out.WorkerCompletionTime + *out = (*in).DeepCopy() + } + if in.CleanerStartTime != nil { + in, out := &in.CleanerStartTime, &out.CleanerStartTime + *out = (*in).DeepCopy() + } + if in.CleanerCompletionTime != nil { + in, out := &in.CleanerCompletionTime, &out.CleanerCompletionTime + *out = (*in).DeepCopy() + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LotusStatus. +func (in *LotusStatus) DeepCopy() *LotusStatus { + if in == nil { + return nil + } + out := new(LotusStatus) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/app/lotus/client/clientset/versioned/BUILD.bazel b/pkg/app/lotus/client/clientset/versioned/BUILD.bazel new file mode 100644 index 0000000..b48179a --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/BUILD.bazel @@ -0,0 +1,17 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "clientset.go", + "doc.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1:go_default_library", + "@io_k8s_client_go//discovery:go_default_library", + "@io_k8s_client_go//rest:go_default_library", + "@io_k8s_client_go//util/flowcontrol:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/clientset/versioned/clientset.go b/pkg/app/lotus/client/clientset/versioned/clientset.go new file mode 100644 index 0000000..468534f --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/clientset.go @@ -0,0 +1,88 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package versioned + +import ( + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1" + discovery "k8s.io/client-go/discovery" + rest "k8s.io/client-go/rest" + flowcontrol "k8s.io/client-go/util/flowcontrol" +) + +type Interface interface { + Discovery() discovery.DiscoveryInterface + LotusV1beta1() lotusv1beta1.LotusV1beta1Interface + // Deprecated: please explicitly pick a version if possible. + Lotus() lotusv1beta1.LotusV1beta1Interface +} + +// Clientset contains the clients for groups. Each group has exactly one +// version included in a Clientset. +type Clientset struct { + *discovery.DiscoveryClient + lotusV1beta1 *lotusv1beta1.LotusV1beta1Client +} + +// LotusV1beta1 retrieves the LotusV1beta1Client +func (c *Clientset) LotusV1beta1() lotusv1beta1.LotusV1beta1Interface { + return c.lotusV1beta1 +} + +// Deprecated: Lotus retrieves the default version of LotusClient. +// Please explicitly pick a version. +func (c *Clientset) Lotus() lotusv1beta1.LotusV1beta1Interface { + return c.lotusV1beta1 +} + +// Discovery retrieves the DiscoveryClient +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + if c == nil { + return nil + } + return c.DiscoveryClient +} + +// NewForConfig creates a new Clientset for the given config. +func NewForConfig(c *rest.Config) (*Clientset, error) { + configShallowCopy := *c + if configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 { + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.lotusV1beta1, err = lotusv1beta1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.lotusV1beta1 = lotusv1beta1.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.lotusV1beta1 = lotusv1beta1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/pkg/app/lotus/client/clientset/versioned/doc.go b/pkg/app/lotus/client/clientset/versioned/doc.go new file mode 100644 index 0000000..7410ea1 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/doc.go @@ -0,0 +1,10 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/pkg/app/lotus/client/clientset/versioned/fake/BUILD.bazel b/pkg/app/lotus/client/clientset/versioned/fake/BUILD.bazel new file mode 100644 index 0000000..2de453a --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/fake/BUILD.bazel @@ -0,0 +1,26 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "clientset_generated.go", + "doc.go", + "register.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/fake", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/client/clientset/versioned:go_default_library", + "//pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/serializer:go_default_library", + "@io_k8s_apimachinery//pkg/watch:go_default_library", + "@io_k8s_client_go//discovery:go_default_library", + "@io_k8s_client_go//discovery/fake:go_default_library", + "@io_k8s_client_go//testing:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/clientset/versioned/fake/clientset_generated.go b/pkg/app/lotus/client/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 0000000..4bd3777 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,72 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned" + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1" + fakelotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + cs := &Clientset{} + cs.discovery = &fakediscovery.FakeDiscovery{Fake: &cs.Fake} + cs.AddReactor("*", "*", testing.ObjectReaction(o)) + cs.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return cs +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +var _ clientset.Interface = &Clientset{} + +// LotusV1beta1 retrieves the LotusV1beta1Client +func (c *Clientset) LotusV1beta1() lotusv1beta1.LotusV1beta1Interface { + return &fakelotusv1beta1.FakeLotusV1beta1{Fake: &c.Fake} +} + +// Lotus retrieves the LotusV1beta1Client +func (c *Clientset) Lotus() lotusv1beta1.LotusV1beta1Interface { + return &fakelotusv1beta1.FakeLotusV1beta1{Fake: &c.Fake} +} diff --git a/pkg/app/lotus/client/clientset/versioned/fake/doc.go b/pkg/app/lotus/client/clientset/versioned/fake/doc.go new file mode 100644 index 0000000..2fb358b --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/fake/doc.go @@ -0,0 +1,10 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/pkg/app/lotus/client/clientset/versioned/fake/register.go b/pkg/app/lotus/client/clientset/versioned/fake/register.go new file mode 100644 index 0000000..6018067 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/fake/register.go @@ -0,0 +1,44 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) +var parameterCodec = runtime.NewParameterCodec(scheme) + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + lotusv1beta1.AddToScheme(scheme) +} diff --git a/pkg/app/lotus/client/clientset/versioned/scheme/BUILD.bazel b/pkg/app/lotus/client/clientset/versioned/scheme/BUILD.bazel new file mode 100644 index 0000000..80d6041 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/scheme/BUILD.bazel @@ -0,0 +1,18 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "doc.go", + "register.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/scheme", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/serializer:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/clientset/versioned/scheme/doc.go b/pkg/app/lotus/client/clientset/versioned/scheme/doc.go new file mode 100644 index 0000000..6d289f2 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/scheme/doc.go @@ -0,0 +1,10 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/pkg/app/lotus/client/clientset/versioned/scheme/register.go b/pkg/app/lotus/client/clientset/versioned/scheme/register.go new file mode 100644 index 0000000..dabcb11 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/scheme/register.go @@ -0,0 +1,44 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + AddToScheme(Scheme) +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +func AddToScheme(scheme *runtime.Scheme) { + lotusv1beta1.AddToScheme(scheme) +} diff --git a/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/BUILD.bazel b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/BUILD.bazel new file mode 100644 index 0000000..651b537 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/BUILD.bazel @@ -0,0 +1,22 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "doc.go", + "generated_expansion.go", + "lotus.go", + "lotus_client.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/client/clientset/versioned/scheme:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/serializer:go_default_library", + "@io_k8s_apimachinery//pkg/types:go_default_library", + "@io_k8s_apimachinery//pkg/watch:go_default_library", + "@io_k8s_client_go//rest:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/doc.go b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/doc.go new file mode 100644 index 0000000..2229cd1 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/doc.go @@ -0,0 +1,10 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1beta1 diff --git a/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/BUILD.bazel b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/BUILD.bazel new file mode 100644 index 0000000..2abc1c2 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/BUILD.bazel @@ -0,0 +1,23 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "doc.go", + "fake_lotus.go", + "fake_lotus_client.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/labels:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library", + "@io_k8s_apimachinery//pkg/types:go_default_library", + "@io_k8s_apimachinery//pkg/watch:go_default_library", + "@io_k8s_client_go//rest:go_default_library", + "@io_k8s_client_go//testing:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/doc.go b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/doc.go new file mode 100644 index 0000000..d7f30ba --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/doc.go @@ -0,0 +1,10 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/fake_lotus.go b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/fake_lotus.go new file mode 100644 index 0000000..75f01ba --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/fake_lotus.go @@ -0,0 +1,130 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + 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" +) + +// FakeLotuses implements LotusInterface +type FakeLotuses struct { + Fake *FakeLotusV1beta1 + ns string +} + +var lotusesResource = schema.GroupVersionResource{Group: "lotus.nghialv.com", Version: "v1beta1", Resource: "lotuses"} + +var lotusesKind = schema.GroupVersionKind{Group: "lotus.nghialv.com", Version: "v1beta1", Kind: "Lotus"} + +// Get takes name of the lotus, and returns the corresponding lotus object, and an error if there is any. +func (c *FakeLotuses) Get(name string, options v1.GetOptions) (result *v1beta1.Lotus, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(lotusesResource, c.ns, name), &v1beta1.Lotus{}) + + if obj == nil { + return nil, err + } + return obj.(*v1beta1.Lotus), err +} + +// List takes label and field selectors, and returns the list of Lotuses that match those selectors. +func (c *FakeLotuses) List(opts v1.ListOptions) (result *v1beta1.LotusList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(lotusesResource, lotusesKind, c.ns, opts), &v1beta1.LotusList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1beta1.LotusList{ListMeta: obj.(*v1beta1.LotusList).ListMeta} + for _, item := range obj.(*v1beta1.LotusList).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 lotuses. +func (c *FakeLotuses) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(lotusesResource, c.ns, opts)) + +} + +// Create takes the representation of a lotus and creates it. Returns the server's representation of the lotus, and an error, if there is any. +func (c *FakeLotuses) Create(lotus *v1beta1.Lotus) (result *v1beta1.Lotus, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(lotusesResource, c.ns, lotus), &v1beta1.Lotus{}) + + if obj == nil { + return nil, err + } + return obj.(*v1beta1.Lotus), err +} + +// Update takes the representation of a lotus and updates it. Returns the server's representation of the lotus, and an error, if there is any. +func (c *FakeLotuses) Update(lotus *v1beta1.Lotus) (result *v1beta1.Lotus, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(lotusesResource, c.ns, lotus), &v1beta1.Lotus{}) + + if obj == nil { + return nil, err + } + return obj.(*v1beta1.Lotus), 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 *FakeLotuses) UpdateStatus(lotus *v1beta1.Lotus) (*v1beta1.Lotus, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(lotusesResource, "status", c.ns, lotus), &v1beta1.Lotus{}) + + if obj == nil { + return nil, err + } + return obj.(*v1beta1.Lotus), err +} + +// Delete takes name of the lotus and deletes it. Returns an error if one occurs. +func (c *FakeLotuses) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(lotusesResource, c.ns, name), &v1beta1.Lotus{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeLotuses) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(lotusesResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1beta1.LotusList{}) + return err +} + +// Patch applies the patch and returns the patched lotus. +func (c *FakeLotuses) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1beta1.Lotus, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(lotusesResource, c.ns, name, data, subresources...), &v1beta1.Lotus{}) + + if obj == nil { + return nil, err + } + return obj.(*v1beta1.Lotus), err +} diff --git a/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/fake_lotus_client.go b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/fake_lotus_client.go new file mode 100644 index 0000000..9035a02 --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/fake/fake_lotus_client.go @@ -0,0 +1,30 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1beta1 "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeLotusV1beta1 struct { + *testing.Fake +} + +func (c *FakeLotusV1beta1) Lotuses(namespace string) v1beta1.LotusInterface { + return &FakeLotuses{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeLotusV1beta1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/generated_expansion.go b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/generated_expansion.go new file mode 100644 index 0000000..588037e --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/generated_expansion.go @@ -0,0 +1,11 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1beta1 + +type LotusExpansion interface{} diff --git a/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/lotus.go b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/lotus.go new file mode 100644 index 0000000..1b012bb --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/lotus.go @@ -0,0 +1,164 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + scheme "github.com/nghialv/lotus/pkg/app/lotus/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" +) + +// LotusesGetter has a method to return a LotusInterface. +// A group's client should implement this interface. +type LotusesGetter interface { + Lotuses(namespace string) LotusInterface +} + +// LotusInterface has methods to work with Lotus resources. +type LotusInterface interface { + Create(*v1beta1.Lotus) (*v1beta1.Lotus, error) + Update(*v1beta1.Lotus) (*v1beta1.Lotus, error) + UpdateStatus(*v1beta1.Lotus) (*v1beta1.Lotus, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1beta1.Lotus, error) + List(opts v1.ListOptions) (*v1beta1.LotusList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1beta1.Lotus, err error) + LotusExpansion +} + +// lotuses implements LotusInterface +type lotuses struct { + client rest.Interface + ns string +} + +// newLotuses returns a Lotuses +func newLotuses(c *LotusV1beta1Client, namespace string) *lotuses { + return &lotuses{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the lotus, and returns the corresponding lotus object, and an error if there is any. +func (c *lotuses) Get(name string, options v1.GetOptions) (result *v1beta1.Lotus, err error) { + result = &v1beta1.Lotus{} + err = c.client.Get(). + Namespace(c.ns). + Resource("lotuses"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Lotuses that match those selectors. +func (c *lotuses) List(opts v1.ListOptions) (result *v1beta1.LotusList, err error) { + result = &v1beta1.LotusList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("lotuses"). + VersionedParams(&opts, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested lotuses. +func (c *lotuses) Watch(opts v1.ListOptions) (watch.Interface, error) { + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("lotuses"). + VersionedParams(&opts, scheme.ParameterCodec). + Watch() +} + +// Create takes the representation of a lotus and creates it. Returns the server's representation of the lotus, and an error, if there is any. +func (c *lotuses) Create(lotus *v1beta1.Lotus) (result *v1beta1.Lotus, err error) { + result = &v1beta1.Lotus{} + err = c.client.Post(). + Namespace(c.ns). + Resource("lotuses"). + Body(lotus). + Do(). + Into(result) + return +} + +// Update takes the representation of a lotus and updates it. Returns the server's representation of the lotus, and an error, if there is any. +func (c *lotuses) Update(lotus *v1beta1.Lotus) (result *v1beta1.Lotus, err error) { + result = &v1beta1.Lotus{} + err = c.client.Put(). + Namespace(c.ns). + Resource("lotuses"). + Name(lotus.Name). + Body(lotus). + Do(). + 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 *lotuses) UpdateStatus(lotus *v1beta1.Lotus) (result *v1beta1.Lotus, err error) { + result = &v1beta1.Lotus{} + err = c.client.Put(). + Namespace(c.ns). + Resource("lotuses"). + Name(lotus.Name). + SubResource("status"). + Body(lotus). + Do(). + Into(result) + return +} + +// Delete takes name of the lotus and deletes it. Returns an error if one occurs. +func (c *lotuses) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("lotuses"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *lotuses) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("lotuses"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched lotus. +func (c *lotuses) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1beta1.Lotus, err error) { + result = &v1beta1.Lotus{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("lotuses"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/lotus_client.go b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/lotus_client.go new file mode 100644 index 0000000..04cd1fd --- /dev/null +++ b/pkg/app/lotus/client/clientset/versioned/typed/lotus/v1beta1/lotus_client.go @@ -0,0 +1,80 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/scheme" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + rest "k8s.io/client-go/rest" +) + +type LotusV1beta1Interface interface { + RESTClient() rest.Interface + LotusesGetter +} + +// LotusV1beta1Client is used to interact with features provided by the lotus.nghialv.com group. +type LotusV1beta1Client struct { + restClient rest.Interface +} + +func (c *LotusV1beta1Client) Lotuses(namespace string) LotusInterface { + return newLotuses(c, namespace) +} + +// NewForConfig creates a new LotusV1beta1Client for the given config. +func NewForConfig(c *rest.Config) (*LotusV1beta1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &LotusV1beta1Client{client}, nil +} + +// NewForConfigOrDie creates a new LotusV1beta1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *LotusV1beta1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new LotusV1beta1Client for the given RESTClient. +func New(c rest.Interface) *LotusV1beta1Client { + return &LotusV1beta1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1beta1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *LotusV1beta1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/pkg/app/lotus/client/informers/externalversions/BUILD.bazel b/pkg/app/lotus/client/informers/externalversions/BUILD.bazel new file mode 100644 index 0000000..f2ce261 --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/BUILD.bazel @@ -0,0 +1,21 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "factory.go", + "generic.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/client/clientset/versioned:go_default_library", + "//pkg/app/lotus/client/informers/externalversions/internalinterfaces:go_default_library", + "//pkg/app/lotus/client/informers/externalversions/lotus:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library", + "@io_k8s_client_go//tools/cache:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/informers/externalversions/factory.go b/pkg/app/lotus/client/informers/externalversions/factory.go new file mode 100644 index 0000000..cb9529d --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/factory.go @@ -0,0 +1,170 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned" + internalinterfaces "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/internalinterfaces" + lotus "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/lotus" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// SharedInformerOption defines the functional option type for SharedInformerFactory. +type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + customResync map[reflect.Type]time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// WithCustomResyncConfig sets a custom resync period for the specified informer types. +func WithCustomResyncConfig(resyncConfig map[v1.Object]time.Duration) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + for k, v := range resyncConfig { + factory.customResync[reflect.TypeOf(k)] = v + } + return factory + } +} + +// WithTweakListOptions sets a custom filter on all listers of the configured SharedInformerFactory. +func WithTweakListOptions(tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.tweakListOptions = tweakListOptions + return factory + } +} + +// WithNamespace limits the SharedInformerFactory to the specified namespace. +func WithNamespace(namespace string) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.namespace = namespace + return factory + } +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory for all namespaces. +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewSharedInformerFactoryWithOptions(client, defaultResync) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +// Deprecated: Please use NewSharedInformerFactoryWithOptions instead +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return NewSharedInformerFactoryWithOptions(client, defaultResync, WithNamespace(namespace), WithTweakListOptions(tweakListOptions)) +} + +// NewSharedInformerFactoryWithOptions constructs a new instance of a SharedInformerFactory with additional options. +func NewSharedInformerFactoryWithOptions(client versioned.Interface, defaultResync time.Duration, options ...SharedInformerOption) SharedInformerFactory { + factory := &sharedInformerFactory{ + client: client, + namespace: v1.NamespaceAll, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + customResync: make(map[reflect.Type]time.Duration), + } + + // Apply all options + for _, opt := range options { + factory = opt(factory) + } + + return factory +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + + resyncPeriod, exists := f.customResync[informerType] + if !exists { + resyncPeriod = f.defaultResync + } + + informer = newFunc(f.client, resyncPeriod) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Lotus() lotus.Interface +} + +func (f *sharedInformerFactory) Lotus() lotus.Interface { + return lotus.New(f, f.namespace, f.tweakListOptions) +} diff --git a/pkg/app/lotus/client/informers/externalversions/generic.go b/pkg/app/lotus/client/informers/externalversions/generic.go new file mode 100644 index 0000000..025ddd5 --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/generic.go @@ -0,0 +1,52 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=lotus.nghialv.com, Version=v1beta1 + case v1beta1.SchemeGroupVersion.WithResource("lotuses"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Lotus().V1beta1().Lotuses().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/pkg/app/lotus/client/informers/externalversions/internalinterfaces/BUILD.bazel b/pkg/app/lotus/client/informers/externalversions/internalinterfaces/BUILD.bazel new file mode 100644 index 0000000..95711eb --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/internalinterfaces/BUILD.bazel @@ -0,0 +1,14 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["factory_interfaces.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/internalinterfaces", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/client/clientset/versioned:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + "@io_k8s_client_go//tools/cache:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/informers/externalversions/internalinterfaces/factory_interfaces.go b/pkg/app/lotus/client/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 0000000..5d26dc1 --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,28 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/pkg/app/lotus/client/informers/externalversions/lotus/BUILD.bazel b/pkg/app/lotus/client/informers/externalversions/lotus/BUILD.bazel new file mode 100644 index 0000000..981951f --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/lotus/BUILD.bazel @@ -0,0 +1,12 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["interface.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/lotus", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/client/informers/externalversions/internalinterfaces:go_default_library", + "//pkg/app/lotus/client/informers/externalversions/lotus/v1beta1:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/informers/externalversions/lotus/interface.go b/pkg/app/lotus/client/informers/externalversions/lotus/interface.go new file mode 100644 index 0000000..168e486 --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/lotus/interface.go @@ -0,0 +1,36 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package lotus + +import ( + internalinterfaces "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/internalinterfaces" + v1beta1 "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1beta1 provides access to shared informers for resources in V1beta1. + V1beta1() v1beta1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1beta1 returns a new v1beta1.Interface. +func (g *group) V1beta1() v1beta1.Interface { + return v1beta1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/BUILD.bazel b/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/BUILD.bazel new file mode 100644 index 0000000..584233f --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/BUILD.bazel @@ -0,0 +1,21 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "interface.go", + "lotus.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/client/clientset/versioned:go_default_library", + "//pkg/app/lotus/client/informers/externalversions/internalinterfaces:go_default_library", + "//pkg/app/lotus/client/listers/lotus/v1beta1:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + "@io_k8s_apimachinery//pkg/watch:go_default_library", + "@io_k8s_client_go//tools/cache:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/interface.go b/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/interface.go new file mode 100644 index 0000000..781b29d --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/interface.go @@ -0,0 +1,35 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1beta1 + +import ( + internalinterfaces "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // Lotuses returns a LotusInformer. + Lotuses() LotusInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// Lotuses returns a LotusInformer. +func (v *version) Lotuses() LotusInformer { + return &lotusInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/lotus.go b/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/lotus.go new file mode 100644 index 0000000..47af03b --- /dev/null +++ b/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1/lotus.go @@ -0,0 +1,79 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1beta1 + +import ( + time "time" + + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + versioned "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned" + internalinterfaces "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/internalinterfaces" + v1beta1 "github.com/nghialv/lotus/pkg/app/lotus/client/listers/lotus/v1beta1" + 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" +) + +// LotusInformer provides access to a shared informer and lister for +// Lotuses. +type LotusInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1beta1.LotusLister +} + +type lotusInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewLotusInformer constructs a new informer for Lotus 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 NewLotusInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredLotusInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredLotusInformer constructs a new informer for Lotus 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 NewFilteredLotusInformer(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.LotusV1beta1().Lotuses(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.LotusV1beta1().Lotuses(namespace).Watch(options) + }, + }, + &lotusv1beta1.Lotus{}, + resyncPeriod, + indexers, + ) +} + +func (f *lotusInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredLotusInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *lotusInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&lotusv1beta1.Lotus{}, f.defaultInformer) +} + +func (f *lotusInformer) Lister() v1beta1.LotusLister { + return v1beta1.NewLotusLister(f.Informer().GetIndexer()) +} diff --git a/pkg/app/lotus/client/listers/lotus/v1beta1/BUILD.bazel b/pkg/app/lotus/client/listers/lotus/v1beta1/BUILD.bazel new file mode 100644 index 0000000..b002c17 --- /dev/null +++ b/pkg/app/lotus/client/listers/lotus/v1beta1/BUILD.bazel @@ -0,0 +1,17 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "expansion_generated.go", + "lotus.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/client/listers/lotus/v1beta1", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "@io_k8s_apimachinery//pkg/api/errors:go_default_library", + "@io_k8s_apimachinery//pkg/labels:go_default_library", + "@io_k8s_client_go//tools/cache:go_default_library", + ], +) diff --git a/pkg/app/lotus/client/listers/lotus/v1beta1/expansion_generated.go b/pkg/app/lotus/client/listers/lotus/v1beta1/expansion_generated.go new file mode 100644 index 0000000..8936f89 --- /dev/null +++ b/pkg/app/lotus/client/listers/lotus/v1beta1/expansion_generated.go @@ -0,0 +1,17 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1beta1 + +// LotusListerExpansion allows custom methods to be added to +// LotusLister. +type LotusListerExpansion interface{} + +// LotusNamespaceListerExpansion allows custom methods to be added to +// LotusNamespaceLister. +type LotusNamespaceListerExpansion interface{} diff --git a/pkg/app/lotus/client/listers/lotus/v1beta1/lotus.go b/pkg/app/lotus/client/listers/lotus/v1beta1/lotus.go new file mode 100644 index 0000000..79c8434 --- /dev/null +++ b/pkg/app/lotus/client/listers/lotus/v1beta1/lotus.go @@ -0,0 +1,84 @@ +/* + +Generated by using code-generator + +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// LotusLister helps list Lotuses. +type LotusLister interface { + // List lists all Lotuses in the indexer. + List(selector labels.Selector) (ret []*v1beta1.Lotus, err error) + // Lotuses returns an object that can list and get Lotuses. + Lotuses(namespace string) LotusNamespaceLister + LotusListerExpansion +} + +// lotusLister implements the LotusLister interface. +type lotusLister struct { + indexer cache.Indexer +} + +// NewLotusLister returns a new LotusLister. +func NewLotusLister(indexer cache.Indexer) LotusLister { + return &lotusLister{indexer: indexer} +} + +// List lists all Lotuses in the indexer. +func (s *lotusLister) List(selector labels.Selector) (ret []*v1beta1.Lotus, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1beta1.Lotus)) + }) + return ret, err +} + +// Lotuses returns an object that can list and get Lotuses. +func (s *lotusLister) Lotuses(namespace string) LotusNamespaceLister { + return lotusNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// LotusNamespaceLister helps list and get Lotuses. +type LotusNamespaceLister interface { + // List lists all Lotuses in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1beta1.Lotus, err error) + // Get retrieves the Lotus from the indexer for a given namespace and name. + Get(name string) (*v1beta1.Lotus, error) + LotusNamespaceListerExpansion +} + +// lotusNamespaceLister implements the LotusNamespaceLister +// interface. +type lotusNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Lotuses in the indexer for a given namespace. +func (s lotusNamespaceLister) List(selector labels.Selector) (ret []*v1beta1.Lotus, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1beta1.Lotus)) + }) + return ret, err +} + +// Get retrieves the Lotus from the indexer for a given namespace and name. +func (s lotusNamespaceLister) Get(name string) (*v1beta1.Lotus, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1beta1.Resource("lotus"), name) + } + return obj.(*v1beta1.Lotus), nil +} diff --git a/pkg/app/lotus/cmd/controller/BUILD.bazel b/pkg/app/lotus/cmd/controller/BUILD.bazel new file mode 100644 index 0000000..4ee2b90 --- /dev/null +++ b/pkg/app/lotus/cmd/controller/BUILD.bazel @@ -0,0 +1,20 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["controller.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/cmd/controller", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/client/clientset/versioned:go_default_library", + "//pkg/app/lotus/client/informers/externalversions:go_default_library", + "//pkg/app/lotus/controller:go_default_library", + "//pkg/cli:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@io_k8s_client_go//informers:go_default_library", + "@io_k8s_client_go//kubernetes:go_default_library", + "@io_k8s_client_go//plugin/pkg/client/auth/gcp:go_default_library", + "@io_k8s_client_go//tools/clientcmd:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/lotus/cmd/controller/controller.go b/pkg/app/lotus/cmd/controller/controller.go new file mode 100644 index 0000000..6b35115 --- /dev/null +++ b/pkg/app/lotus/cmd/controller/controller.go @@ -0,0 +1,102 @@ +package controller + +import ( + "context" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + kubeinformers "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" + "k8s.io/client-go/tools/clientcmd" + + clientset "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned" + informers "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions" + lotus "github.com/nghialv/lotus/pkg/app/lotus/controller" + "github.com/nghialv/lotus/pkg/cli" +) + +type controller struct { + kubeconfig string + masterURL string + namespace string + release string + prometheusServiceAccount string + configFile string +} + +func NewCommand() *cobra.Command { + c := &controller{ + namespace: "default", + release: "lotus", + } + cmd := &cobra.Command{ + Use: "controller", + Short: "Start running Lotus controller", + RunE: cli.WithContext(c.run), + } + cmd.Flags().StringVar(&c.kubeconfig, "kube-config", c.kubeconfig, "Path to a kubeconfig. Only required if out-of-cluster.") + cmd.Flags().StringVar(&c.masterURL, "master", c.masterURL, "The address of the Kubernetes API server. Overrides any value in kubeconfig. Only required if out-of-cluster.") + cmd.Flags().StringVar(&c.namespace, "namespace", c.namespace, "The namespace of controller.") + cmd.Flags().StringVar(&c.release, "release", c.release, "The release name of deployment.") + cmd.Flags().StringVar(&c.prometheusServiceAccount, "prometheus-service-account", c.prometheusServiceAccount, "The name of service account for prometheus pods. This is required when rbac is enabled.") + cmd.Flags().StringVar(&c.configFile, "config-file", c.configFile, "Path to the configuration file.") + cmd.MarkFlagRequired("config-file") + return cmd +} + +func (c *controller) run(ctx context.Context, logger *zap.Logger) error { + cfg, err := clientcmd.BuildConfigFromFlags(c.masterURL, c.kubeconfig) + if err != nil { + logger.Error("failed to build kube config", zap.Error(err)) + return err + } + + kubeClient, err := kubernetes.NewForConfig(cfg) + if err != nil { + logger.Error("failed to build kubernetes clientset", zap.Error(err)) + return err + } + + lotusClient, err := clientset.NewForConfig(cfg) + if err != nil { + logger.Error("failed to build lotus clientset", zap.Error(err)) + return err + } + + kubeInformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions( + kubeClient, + 30*time.Second, + kubeinformers.WithNamespace(c.namespace), + ) + + lotusInformerFactory := informers.NewSharedInformerFactoryWithOptions( + lotusClient, + 30*time.Second, + informers.WithNamespace(c.namespace), + ) + + controller := lotus.NewController( + kubeClient, + lotusClient, + kubeInformerFactory.Batch().V1().Jobs(), + lotusInformerFactory.Lotus().V1beta1().Lotuses(), + c.namespace, + c.release, + c.prometheusServiceAccount, + c.configFile, + logger, + ) + + kubeInformerFactory.Start(ctx.Done()) + lotusInformerFactory.Start(ctx.Done()) + + if err = controller.Run(ctx, 1); err != nil { + logger.Error("failed to run controller", zap.Error(err)) + return err + } + + <-ctx.Done() + return nil +} diff --git a/pkg/app/lotus/cmd/monitor/BUILD.bazel b/pkg/app/lotus/cmd/monitor/BUILD.bazel new file mode 100644 index 0000000..2b57b56 --- /dev/null +++ b/pkg/app/lotus/cmd/monitor/BUILD.bazel @@ -0,0 +1,19 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["monitor.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/cmd/monitor", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/datasource:go_default_library", + "//pkg/app/lotus/datasource/registry:go_default_library", + "//pkg/app/lotus/model:go_default_library", + "//pkg/app/lotus/reporter:go_default_library", + "//pkg/app/lotus/reporter/registry:go_default_library", + "//pkg/cli:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/lotus/cmd/monitor/monitor.go b/pkg/app/lotus/cmd/monitor/monitor.go new file mode 100644 index 0000000..b983a7c --- /dev/null +++ b/pkg/app/lotus/cmd/monitor/monitor.go @@ -0,0 +1,239 @@ +package monitor + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/datasource" + dsregistry "github.com/nghialv/lotus/pkg/app/lotus/datasource/registry" + "github.com/nghialv/lotus/pkg/app/lotus/model" + "github.com/nghialv/lotus/pkg/app/lotus/reporter" + reporterregistry "github.com/nghialv/lotus/pkg/app/lotus/reporter/registry" + "github.com/nghialv/lotus/pkg/cli" +) + +type monitor struct { + testID string + runTime time.Duration + checkInterval time.Duration + checkInitialDelay time.Duration + collectSummaryDataSource string + collectAndReportTimeout time.Duration + configFile string + + dataSourceMap map[string]datasource.DataSource + checkMap map[string][]datasource.Check + cfg *config.Config + logger *zap.Logger +} + +func NewCommand() *cobra.Command { + m := &monitor{ + runTime: 2 * time.Minute, + checkInterval: 30 * time.Second, + checkInitialDelay: 10 * time.Second, + collectAndReportTimeout: 30 * time.Minute, + } + cmd := &cobra.Command{ + Use: "monitor", + Short: "Start running Lotus monitor", + RunE: cli.WithContext(m.run), + } + cmd.Flags().StringVar(&m.testID, "test-id", m.testID, "The unique test id") + cmd.MarkFlagRequired("test-id") + cmd.Flags().DurationVar(&m.runTime, "run-time", m.runTime, "How long the worker should be run") + cmd.Flags().DurationVar(&m.checkInterval, "check-interval", m.checkInterval, "How often does the monitor run the check") + cmd.Flags().DurationVar(&m.checkInitialDelay, "check-initial-delay", m.checkInitialDelay, "How long the monitor should wait before performing the first check") + cmd.Flags().StringVar(&m.collectSummaryDataSource, "collect-summary-datasource", m.collectSummaryDataSource, "The datasource used to collect test summary") + cmd.MarkFlagRequired("collect-summary-datasource") + cmd.Flags().DurationVar(&m.collectAndReportTimeout, "collect-and-report-timeout", m.collectAndReportTimeout, "How log to wait for collect and report tasks") + cmd.Flags().StringVar(&m.configFile, "config-file", m.configFile, "Path to the configuration file") + cmd.MarkFlagRequired("config-file") + return cmd +} + +func (m *monitor) run(ctx context.Context, logger *zap.Logger) (lastErr error) { + startTime := time.Now() + m.logger = logger.Named("monitor") + ctx, cancel := context.WithTimeout(ctx, m.runTime) + defer cancel() + + defer func() { + if err := m.collectAndReport(startTime, time.Now(), lastErr); err != nil { + lastErr = err + } + }() + + cfg, err := config.FromFile(m.configFile) + if err != nil { + logger.Error("failed to load configuration", zap.Error(err)) + lastErr = err + return + } + m.cfg = cfg + dataSourceMap, err := buildDataSourceMap(cfg, logger) + if err != nil { + logger.Error("failed to build dataSourceMap", zap.Error(err)) + lastErr = err + return + } + m.dataSourceMap = dataSourceMap + m.checkMap = buildCheckMap(cfg) + + // Waiting for initial delay + select { + case <-time.After(m.checkInitialDelay): + case <-ctx.Done(): + } + + tick := time.Tick(m.checkInterval) + for { + select { + case <-tick: + lastErr = m.check(ctx) + if lastErr != nil { + return + } + case <-ctx.Done(): + m.logger.Info("breaking the check loop due to the context deadline") + return + } + } +} + +func (m *monitor) check(ctx context.Context) error { + actives := make([]string, 0) + m.logger.Info("start checking all datasources", zap.Int("num", len(m.dataSourceMap))) + for dsn, checks := range m.checkMap { + ds, ok := m.dataSourceMap[dsn] + if !ok { + err := fmt.Errorf("missing datasource: %s", dsn) + m.logger.Error("failed to get datasource", zap.Error(err)) + return err + } + result, err := ds.Check(ctx, checks) + if err != nil { + m.logger.Error("failed to check", zap.Error(err)) + return err + } + actives = append(actives, result.Actives...) + } + if len(actives) == 0 { + return nil + } + m.logger.Info("active checks", zap.Any("actives", actives)) + return checkError{ + Actives: actives, + } +} + +type checkError struct { + Actives []string +} + +func (ce checkError) Error() string { + return fmt.Sprintf("%d checks are failed", len(ce.Actives)) +} + +func (m *monitor) collectAndReport(startTime, finishTime time.Time, lastErr error) error { + ctx, cancel := context.WithTimeout(context.Background(), m.collectAndReportTimeout) + defer cancel() + result := &model.Result{ + TestID: m.testID, + Status: model.TestSucceeded, + StartedTimestamp: startTime, + FinishedTimestamp: finishTime, + } + if lastErr != nil { + result.SetFailed(lastErr.Error()) + } + if ce, ok := lastErr.(checkError); ok { + result.FailedChecks = ce.Actives + } + + summary, collectErr := m.collect(ctx) + if collectErr != nil { + m.logger.Error("failed to collect metrics summary", zap.Error(collectErr)) + if result.Status != model.TestFailed { + result.SetFailed("failed to collect metrics summary") + } + } else { + result.MetricsSummary = summary + } + if m.cfg != nil { + result.SetGrafanaDashboardURLs(m.cfg.GrafanaBaseUrl) + } + if err := m.report(ctx, result); err != nil { + m.logger.Error("failed to report result", zap.Error(err)) + return err + } + return collectErr +} + +func (m *monitor) collect(ctx context.Context) (*model.MetricsSummary, error) { + ds, ok := m.dataSourceMap[m.collectSummaryDataSource] + if !ok { + err := fmt.Errorf("missing datasource for collecting test summary: %s", m.collectSummaryDataSource) + m.logger.Error("failed to get datasource", zap.Error(err)) + return nil, err + } + return ds.CollectSummary(ctx, time.Now()) +} + +func (m *monitor) report(ctx context.Context, result *model.Result) error { + rs := make([]reporter.Reporter, 0, len(m.cfg.Receivers)) + for _, recv := range m.cfg.Receivers { + builder, err := reporterregistry.Default().Get(recv.ReceiverType()) + if err != nil { + return err + } + r, err := builder.Build(recv, reporter.BuildOptions{ + Logger: m.logger, + }) + if err != nil { + return err + } + rs = append(rs, r) + } + return reporter.MultiReporter(rs...).Report(ctx, result) +} + +func buildDataSourceMap(cfg *config.Config, logger *zap.Logger) (map[string]datasource.DataSource, error) { + datasources := make(map[string]datasource.DataSource, len(cfg.DataSources)) + for _, ds := range cfg.DataSources { + builder, err := dsregistry.Default().Get(ds.DataSourceType()) + if err != nil { + return nil, err + } + datasource, err := builder.Build(ds, datasource.BuildOptions{ + Logger: logger, + }) + if err != nil { + return nil, err + } + datasources[ds.Name] = datasource + } + return datasources, nil +} + +func buildCheckMap(cfg *config.Config) map[string][]datasource.Check { + checkMap := make(map[string][]datasource.Check) + for _, check := range cfg.Checks { + c := datasource.Check{ + Name: check.Name, + Expr: check.Expr, + For: check.For, + } + if list, ok := checkMap[check.DataSource]; ok { + checkMap[check.DataSource] = append(list, c) + continue + } + checkMap[check.DataSource] = []datasource.Check{c} + } + return checkMap +} diff --git a/pkg/app/lotus/config/BUILD.bazel b/pkg/app/lotus/config/BUILD.bazel new file mode 100644 index 0000000..da07069 --- /dev/null +++ b/pkg/app/lotus/config/BUILD.bazel @@ -0,0 +1,43 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") +load("//:pgv_proto_library.bzl", "pgv_go_proto_library") + +pgv_go_proto_library( + name = "config_go_proto", + proto = ":config_proto", + importpath = "github.com/nghialv/lotus/pkg/config", + deps = [ + "@com_github_golang_protobuf//ptypes:go_default_library_gen", + ], +) + +proto_library( + name = "config_proto", + srcs = ["config.proto"], + visibility = ["//visibility:public"], + deps = ["@com_lyft_protoc_gen_validate//validate:validate_proto"], #keep +) + +go_library( + name = "go_default_library", + srcs = ["config.go"], + embed = [":config_go_proto"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/config", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "@com_github_ghodss_yaml//:go_default_library", + "@com_github_golang_protobuf//jsonpb:go_default_library_gen", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["config_test.go"], + data = glob(["testdata/**"]), + embed = [":go_default_library"], + deps = [ + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/pkg/app/lotus/config/config.go b/pkg/app/lotus/config/config.go new file mode 100644 index 0000000..65bdc66 --- /dev/null +++ b/pkg/app/lotus/config/config.go @@ -0,0 +1,97 @@ +package config + +import ( + "fmt" + "io/ioutil" + + "github.com/ghodss/yaml" + "github.com/golang/protobuf/jsonpb" + + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" +) + +func (c *Config) AddChecks(checks ...lotusv1beta1.LotusCheck) { + for i := range checks { + c.Checks = append(c.Checks, &Check{ + Name: checks[i].Name, + Expr: checks[i].Expr, + For: checks[i].For, + DataSource: checks[i].DataSource, + }) + } +} + +func (c *Config) LotusChecks() []lotusv1beta1.LotusCheck { + checks := make([]lotusv1beta1.LotusCheck, 0, len(c.Checks)) + for _, check := range c.Checks { + checks = append(checks, lotusv1beta1.LotusCheck{ + Name: check.Name, + Expr: check.Expr, + For: check.For, + DataSource: check.DataSource, + }) + } + return checks +} + +func (ds *DataSource) DataSourceType() DataSource_Type { + switch ds.Type.(type) { + case *DataSource_Prometheus: + return DataSource_PROMETHEUS + default: + return DataSource_UNKNOWN + } +} + +func (r *Receiver) ReceiverType() Receiver_Type { + switch r.Type.(type) { + case *Receiver_Logger: + return Receiver_LOGGER + case *Receiver_Gcs: + return Receiver_GCS + case *Receiver_Slack: + return Receiver_SLACK + default: + return Receiver_UNKNOWN + } +} + +func (r *Receiver) CredentialsMountPath() string { + return fmt.Sprintf("/etc/creds/%s/", r.Name) +} + +func (r *Receiver) CredentialsFile(filename string) string { + return fmt.Sprintf("%s%s", r.CredentialsMountPath(), filename) +} + +func FromFile(file string) (*Config, error) { + data, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + return UnmarshalFromYaml(data) +} + +func UnmarshalFromYaml(data []byte) (*Config, error) { + json, err := yaml.YAMLToJSON(data) + if err != nil { + return nil, err + } + config := &Config{} + if err = jsonpb.UnmarshalString(string(json), config); err != nil { + return nil, err + } + if err := config.Validate(); err != nil { + return nil, err + } + return config, nil +} + +func (c *Config) MarshalToYaml() ([]byte, error) { + marshaler := &jsonpb.Marshaler{} + json, err := marshaler.MarshalToString(c) + if err != nil { + return nil, err + } + return yaml.JSONToYAML([]byte(json)) +} diff --git a/pkg/app/lotus/config/config.proto b/pkg/app/lotus/config/config.proto new file mode 100644 index 0000000..c58f254 --- /dev/null +++ b/pkg/app/lotus/config/config.proto @@ -0,0 +1,99 @@ +syntax = "proto3"; + +package pkg.lotus.config; +option go_package = "config"; + +import "validate/validate.proto"; + +message Config { + repeated DataSource data_sources = 1; + repeated Check checks = 2; + repeated Receiver receivers = 3; + TimeSeriesStorage time_series_storage = 4; + string grafana_base_url = 5; +} + +message TimeSeriesStorage { + enum Type { + GCS = 0; + AWS_S3 = 1; + } + oneof type { + option (validate.required) = true; + GCSTimeSeriesStorageConfigs gcs = 10; + S3TimeSeriesStorageConfigs s3 = 11; + } +} + +message GCSTimeSeriesStorageConfigs { + string bucket = 1 [(validate.rules).string.min_len = 1]; + SecretFileSelector credentials = 2; +} + +message S3TimeSeriesStorageConfigs { + string bucket = 1 [(validate.rules).string.min_len = 1]; + string endpoint = 2 [(validate.rules).string.min_len = 1]; + SecretFileSelector access_key = 3 [(validate.rules).message.required = true]; + SecretFileSelector secret_key = 4 [(validate.rules).message.required = true]; + bool insecure = 5; + bool signature_version2 = 6; + bool encrypt_sse = 7; +} + +message Check { + string name = 1 [(validate.rules).string.min_len = 1]; + string expr = 2 [(validate.rules).string.min_len = 1]; + string for = 3 [(validate.rules).string.min_len = 1]; + string data_source = 4; +} + +message DataSource { + enum Type { + PROMETHEUS = 0; + STACKDRIVER = 1; + DATADOG = 2; + UNKNOWN = 15; + } + string name = 1 [(validate.rules).string.min_len = 1]; + oneof type { + option (validate.required) = true; + PrometheusConfigs prometheus = 10; + } +} + +message PrometheusConfigs { + string address = 1 [(validate.rules).string.uri = true]; +} + +message Receiver { + enum Type { + LOGGER = 0; + GCS = 1; + SLACK = 2; + UNKNOWN = 15; + } + string name = 1 [(validate.rules).string.min_len = 1]; + oneof type { + option (validate.required) = true; + LoggerReceiverConfigs logger = 10; + GCSReceiverConfigs gcs = 11; + SlackReceiverConfigs slack = 12; + } +} + +message LoggerReceiverConfigs { +} + +message GCSReceiverConfigs { + string bucket = 1 [(validate.rules).string.min_len = 1]; + SecretFileSelector credentials = 2; +} + +message SlackReceiverConfigs { + string hook_url = 1 [(validate.rules).string.uri = true]; +} + +message SecretFileSelector { + string secret = 1 [(validate.rules).string.min_len = 1]; + string file = 2 [(validate.rules).string.min_len =1]; +} diff --git a/pkg/app/lotus/config/config_test.go b/pkg/app/lotus/config/config_test.go new file mode 100644 index 0000000..6045505 --- /dev/null +++ b/pkg/app/lotus/config/config_test.go @@ -0,0 +1,97 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFromFile(t *testing.T) { + cfg, err := FromFile("testdata/valid.yaml") + require.NoError(t, err) + require.NotNil(t, cfg) + + require.NotNil(t, cfg.TimeSeriesStorage) + gcs, ok := cfg.TimeSeriesStorage.Type.(*TimeSeriesStorage_Gcs) + require.True(t, ok) + assert.Equal(t, "gcs-bucket", gcs.Gcs.Bucket) + assert.NotNil(t, gcs.Gcs.Credentials) + assert.Equal(t, 1, len(cfg.DataSources)) + assert.Equal(t, 1, len(cfg.Checks)) + assert.Equal(t, 3, len(cfg.Receivers)) +} + +func TestMarshaling(t *testing.T) { + configs := []*Config{ + &Config{}, + &Config{ + DataSources: []*DataSource{ + &DataSource{ + Name: "prometheus", + Type: &DataSource_Prometheus{ + Prometheus: &PrometheusConfigs{ + Address: "https://127.0.0.1:9090", + }, + }, + }, + }, + Checks: []*Check{ + &Check{ + Name: "HighErrorRate", + Expr: "error_rate > 0.5", + For: "1m", + }, + &Check{ + Name: "HighLatency", + Expr: "latency > 125", + For: "1m", + DataSource: "prometheus", + }, + }, + Receivers: []*Receiver{ + &Receiver{ + Name: "gcs", + Type: &Receiver_Gcs{ + Gcs: &GCSReceiverConfigs{ + Bucket: "bucket-2", + Credentials: &SecretFileSelector{ + Secret: "foo", + File: "credentials-2", + }, + }, + }, + }, + &Receiver{ + Name: "slack", + Type: &Receiver_Slack{ + Slack: &SlackReceiverConfigs{ + HookUrl: "http://api-2.slack.com", + }, + }, + }, + }, + TimeSeriesStorage: &TimeSeriesStorage{ + Type: &TimeSeriesStorage_Gcs{ + Gcs: &GCSTimeSeriesStorageConfigs{ + Bucket: "gcs-bucket", + Credentials: &SecretFileSelector{ + Secret: "secret-name", + File: "filename", + }, + }, + }, + }, + }, + } + for _, cfg := range configs { + require.NoError(t, cfg.Validate()) + + data, err := cfg.MarshalToYaml() + require.NoError(t, err) + + unmarshaledCfg, err := UnmarshalFromYaml(data) + require.NoError(t, err) + assert.Equal(t, cfg, unmarshaledCfg) + } +} diff --git a/pkg/app/lotus/config/testdata/valid.yaml b/pkg/app/lotus/config/testdata/valid.yaml new file mode 100644 index 0000000..8b73686 --- /dev/null +++ b/pkg/app/lotus/config/testdata/valid.yaml @@ -0,0 +1,26 @@ +timeSeriesStorage: + gcs: + bucket: gcs-bucket + credentials: + secret: gcs-credentials + file: gcs-credentials.json +dataSources: + - name: RemotePrometheus + prometheus: + address: http://prometheus.com +checks: + - name: NoWorker + expr: absent(up) + for: 30s +receivers: + - name: gcs + gcs: + bucket: load-testing-result + credentials: + secret: secret-name + file: filename + - name: slack + slack: + hookUrl: https://slack.com/hook + - name: logger + logger: diff --git a/pkg/app/lotus/controller/BUILD.bazel b/pkg/app/lotus/controller/BUILD.bazel new file mode 100644 index 0000000..5370b83 --- /dev/null +++ b/pkg/app/lotus/controller/BUILD.bazel @@ -0,0 +1,41 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["controller.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/controller", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/client/clientset/versioned:go_default_library", + "//pkg/app/lotus/client/clientset/versioned/scheme:go_default_library", + "//pkg/app/lotus/client/informers/externalversions/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/client/listers/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/kubeclient:go_default_library", + "//pkg/app/lotus/model:go_default_library", + "//pkg/app/lotus/resource:go_default_library", + "@io_k8s_api//apps/v1:go_default_library", + "@io_k8s_api//core/v1:go_default_library", + "@io_k8s_apimachinery//pkg/api/errors:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library", + "@io_k8s_apimachinery//pkg/util/runtime:go_default_library", + "@io_k8s_apimachinery//pkg/util/wait:go_default_library", + "@io_k8s_client_go//informers/batch/v1:go_default_library", + "@io_k8s_client_go//kubernetes:go_default_library", + "@io_k8s_client_go//kubernetes/scheme:go_default_library", + "@io_k8s_client_go//kubernetes/typed/core/v1:go_default_library", + "@io_k8s_client_go//tools/cache:go_default_library", + "@io_k8s_client_go//tools/record:go_default_library", + "@io_k8s_client_go//util/workqueue:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["controller_test.go"], + embed = [":go_default_library"], +) diff --git a/pkg/app/lotus/controller/controller.go b/pkg/app/lotus/controller/controller.go new file mode 100644 index 0000000..1331c9f --- /dev/null +++ b/pkg/app/lotus/controller/controller.go @@ -0,0 +1,466 @@ +package controller + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + batchinformers "k8s.io/client-go/informers/batch/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" + + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + clientset "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned" + lotusscheme "github.com/nghialv/lotus/pkg/app/lotus/client/clientset/versioned/scheme" + informers "github.com/nghialv/lotus/pkg/app/lotus/client/informers/externalversions/lotus/v1beta1" + listers "github.com/nghialv/lotus/pkg/app/lotus/client/listers/lotus/v1beta1" + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/kubeclient" + "github.com/nghialv/lotus/pkg/app/lotus/model" + "github.com/nghialv/lotus/pkg/app/lotus/resource" +) + +type Controller struct { + kubeClient kubeclient.KubeClient + lotusclientset clientset.Interface + + jobsSynced cache.InformerSynced + lotusesLister listers.LotusLister + lotusesSynced cache.InformerSynced + + workqueue workqueue.RateLimitingInterface + recorder record.EventRecorder + + namespace string + release string + prometheusServiceAccount string + configFile string + logger *zap.Logger +} + +func NewController( + kubeclientset kubernetes.Interface, + lotusclientset clientset.Interface, + jobInformer batchinformers.JobInformer, + lotusInformer informers.LotusInformer, + namespace string, + release string, + prometheusServiceAccount string, + configFile string, + logger *zap.Logger) *Controller { + + logger = logger.Named("controller") + logger.Info("creating event broadcaster") + lotusscheme.AddToScheme(scheme.Scheme) + eventBroadcaster := record.NewBroadcaster() + eventBroadcaster.StartLogging(logger.Sugar().Infof) + eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{ + Interface: kubeclientset.CoreV1().Events(""), + }) + recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{ + Component: "lotus-controller", + }) + + controller := &Controller{ + kubeClient: kubeclient.New(kubeclientset, jobInformer.Lister()), + lotusclientset: lotusclientset, + jobsSynced: jobInformer.Informer().HasSynced, + lotusesLister: lotusInformer.Lister(), + lotusesSynced: lotusInformer.Informer().HasSynced, + workqueue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "Lotuses"), + recorder: recorder, + namespace: namespace, + release: release, + prometheusServiceAccount: prometheusServiceAccount, + configFile: configFile, + logger: logger, + } + lotusInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: controller.enqueueLotus, + UpdateFunc: func(old, new interface{}) { + controller.enqueueLotus(new) + }, + }) + jobInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + UpdateFunc: func(old, new interface{}) { + controller.onObject(new) + }, + }) + return controller +} + +func (c *Controller) Run(ctx context.Context, workers int) error { + defer runtime.HandleCrash() + defer c.workqueue.ShutDown() + + c.logger.Info("starting Lotus controller") + c.logger.Info("waiting for informer caches to sync") + if ok := cache.WaitForCacheSync(ctx.Done(), c.jobsSynced, c.lotusesSynced); !ok { + return fmt.Errorf("failed to wait for caches to sync") + } + + // Update static resources based on new configuration + c.logger.Info("updating static resources") + if err := c.ensureStaticResources(); err != nil { + c.logger.Error("failed to ensure static resources", zap.Error(err)) + return err + } + + c.logger.Info("informer caches synced") + c.logger.Info("starting workers") + for i := 0; i < workers; i++ { + go wait.Until(c.runWorker, time.Second, ctx.Done()) + } + + c.logger.Info("started workers", zap.Int("workers", workers)) + <-ctx.Done() + c.logger.Info("shutting down workers") + return nil +} + +func (c *Controller) runWorker() { + for c.processNextWorkItem() { + } +} + +func (c *Controller) processNextWorkItem() bool { + obj, shutdown := c.workqueue.Get() + if shutdown { + return false + } + err := func(obj interface{}) error { + defer c.workqueue.Done(obj) + key, ok := obj.(string) + if !ok { + c.workqueue.Forget(obj) + runtime.HandleError(fmt.Errorf("expected string in workqueue but got %#v", obj)) + return nil + } + if err := c.syncHandler(key); err != nil { + // Put the item back on the workqueue to handle any transient errors. + c.workqueue.AddRateLimited(key) + return fmt.Errorf("error syncing '%s': %s, requeuing", key, err.Error()) + } + // Finally, if no error occurs we Forget this item so it does not + // get queued again until another change happens. + c.workqueue.Forget(obj) + c.logger.Info("successfully synced item", zap.String("key", key)) + return nil + }(obj) + + if err != nil { + runtime.HandleError(err) + return true + } + return true +} + +// Compares the actual state with the desired and attempts to converge the two. +// It then updates the Status block of the Lotus resource with the current status of the resource. +func (c *Controller) syncHandler(key string) error { + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + runtime.HandleError(fmt.Errorf("invalid resource key: %s", key)) + return nil + } + lotus, err := c.lotusesLister.Lotuses(namespace).Get(name) + if err != nil { + if errors.IsNotFound(err) { + runtime.HandleError(fmt.Errorf("lotus '%s' in work queue no longer exists", key)) + return nil + } + return err + } + + switch lotus.Status.Phase { + case lotusv1beta1.LotusInit: + return c.updateLotusStatus(lotus, lotusv1beta1.LotusPending) + case lotusv1beta1.LotusPending: + return c.updateLotusStatus(lotus, lotusv1beta1.LotusPreparing) + case lotusv1beta1.LotusPreparing: + if lotus.Spec.Preparer == nil { + return c.toRunningPhase(lotus) + } + return c.syncPreparingLotus(lotus) + case lotusv1beta1.LotusRunning: + return c.syncRunningLotus(lotus) + case lotusv1beta1.LotusCleaning: + if lotus.Spec.Cleaner == nil { + return c.updateLotusStatus(lotus, lotusv1beta1.LotusSucceeded) + } + return c.syncCleaningLotus(lotus) + case lotusv1beta1.LotusFailureCleaning: + if lotus.Spec.Cleaner == nil { + return c.updateLotusStatus(lotus, lotusv1beta1.LotusFailed) + } + return c.syncFailureCleaningLotus(lotus) + case lotusv1beta1.LotusSucceeded: + return nil + case lotusv1beta1.LotusFailed: + return nil + } + c.logger.Warn("unexpected lotus phase", zap.String("phase", string(lotus.Status.Phase))) + return nil +} + +func (c *Controller) syncPreparingLotus(lotus *lotusv1beta1.Lotus) error { + factory := resource.NewFactory(lotus, c.configFile) + jobName := factory.PreparerJobName() + job, err := c.kubeClient.EnsureJob(jobName, lotus.Namespace, factory.NewPreparerJob) + if err != nil { + return err + } + if job.Status.Failed > 0 { + return c.updateLotusStatus(lotus, lotusv1beta1.LotusFailureCleaning) + } + if job.Status.Succeeded > 0 { + return c.toRunningPhase(lotus) + } + c.logger.Info("preparer job is still running", zap.String("name", jobName)) + return nil +} + +func (c *Controller) toRunningPhase(lotus *lotusv1beta1.Lotus) error { + if err := c.ensurePrometheusResources(lotus); err != nil { + return err + } + if err := c.ensureWorkerResources(lotus); err != nil { + return err + } + factory := resource.NewFactory(lotus, c.configFile) + name := factory.MonitorJobName() + if _, err := c.kubeClient.EnsureConfigMap(name, lotus.Namespace, factory.NewMonitorConfigMap); err != nil { + return err + } + return c.updateLotusStatus(lotus, lotusv1beta1.LotusRunning) +} + +func (c *Controller) syncRunningLotus(lotus *lotusv1beta1.Lotus) error { + factory := resource.NewFactory(lotus, c.configFile) + jobName := factory.MonitorJobName() + job, err := c.kubeClient.EnsureJob(jobName, lotus.Namespace, factory.NewMonitorJob) + if err != nil { + return err + } + if job.Status.Succeeded == 0 && job.Status.Failed == 0 { + c.logger.Info("monitor job is still running", zap.String("name", jobName)) + return nil + } + // Scale down or Delete worker deployment. + workerName := factory.WorkerName() + err = c.kubeClient.DeleteDeployment(workerName, lotus.Namespace) + if err != nil { + c.logger.Error("failed to delete worker deployment", zap.Error(err)) + return err + } + if job.Status.Failed > 0 { + return c.updateLotusStatus(lotus, lotusv1beta1.LotusFailureCleaning) + } + return c.updateLotusStatus(lotus, lotusv1beta1.LotusCleaning) +} + +func (c *Controller) syncCleaningLotus(lotus *lotusv1beta1.Lotus) error { + factory := resource.NewFactory(lotus, c.configFile) + jobName := factory.CleanerJobName() + job, err := c.kubeClient.EnsureJob(jobName, lotus.Namespace, factory.NewCleanerJob) + if err != nil { + return err + } + if job.Status.Succeeded > 0 { + return c.updateLotusStatus(lotus, lotusv1beta1.LotusSucceeded) + } + if job.Status.Failed > 0 { + return c.updateLotusStatus(lotus, lotusv1beta1.LotusFailed) + } + return nil +} + +func (c *Controller) syncFailureCleaningLotus(lotus *lotusv1beta1.Lotus) error { + factory := resource.NewFactory(lotus, c.configFile) + jobName := factory.CleanerJobName() + job, err := c.kubeClient.EnsureJob(jobName, lotus.Namespace, factory.NewCleanerJob) + if err != nil { + return err + } + if job.Status.Succeeded > 0 || job.Status.Failed > 0 { + return c.updateLotusStatus(lotus, lotusv1beta1.LotusFailed) + } + return nil +} + +func (c *Controller) ensureWorkerResources(lotus *lotusv1beta1.Lotus) error { + factory := resource.NewFactory(lotus, c.configFile) + name := factory.WorkerName() + if _, err := c.kubeClient.EnsureService(name, lotus.Namespace, factory.NewWorkerService); err != nil { + return err + } + _, err := c.kubeClient.EnsureDeployment(name, lotus.Namespace, factory.NewWorkerDeployment) + return err +} + +func (c *Controller) ensurePrometheusResources(lotus *lotusv1beta1.Lotus) error { + factory := resource.NewFactory(lotus, c.configFile) + name := factory.PrometheusName() + if _, err := c.kubeClient.EnsureConfigMap(name, lotus.Namespace, factory.NewPrometheusConfigMap); err != nil { + return err + } + podFactory := func() (*corev1.Pod, error) { + return factory.NewPrometheusPod(c.prometheusServiceAccount, c.release) + } + if _, err := c.kubeClient.EnsurePod(name, lotus.Namespace, podFactory); err != nil { + return err + } + _, err := c.kubeClient.EnsureService(name, lotus.Namespace, factory.NewPrometheusService) + return err +} + +func (c *Controller) enqueueLotus(obj interface{}) { + key, err := cache.MetaNamespaceKeyFunc(obj) + if err != nil { + runtime.HandleError(err) + return + } + c.logger.Info("enqueue a lotus", zap.String("key", key)) + c.workqueue.AddRateLimited(key) +} + +func (c *Controller) onObject(obj interface{}) { + object, ok := obj.(metav1.Object) + if !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + runtime.HandleError(fmt.Errorf("error decoding object, invalid type")) + return + } + object, ok := tombstone.Obj.(metav1.Object) + if !ok { + runtime.HandleError(fmt.Errorf("error decoding object tombstone, invalid type")) + return + } + c.logger.Info("recovered deleted object from tombstone", zap.String("name", object.GetName())) + } + ownerRef := metav1.GetControllerOf(object) + if ownerRef == nil { + return + } + if ownerRef.Kind != model.LotusKind { + return + } + lotus, err := c.lotusesLister.Lotuses(object.GetNamespace()).Get(ownerRef.Name) + if err != nil { + c.logger.Info("ignoring orphaned object", + zap.String("object_self_link", object.GetSelfLink()), + zap.String("lotus_name", ownerRef.Name)) + return + } + c.logger.Info("will enqueue new lotus because of an object change", + zap.String("object_self_link", object.GetSelfLink()), + zap.String("object_name", object.GetName())) + c.enqueueLotus(lotus) +} + +func (c *Controller) updateLotusStatus(lotus *lotusv1beta1.Lotus, phase lotusv1beta1.LotusPhase) error { + lotus = copyWithNewStatus(lotus, phase) + _, err := c.lotusclientset.LotusV1beta1().Lotuses(lotus.Namespace).Update(lotus) + return err +} + +func copyWithNewStatus(lotus *lotusv1beta1.Lotus, phase lotusv1beta1.LotusPhase) *lotusv1beta1.Lotus { + lotusCopy := lotus.DeepCopy() + prev := lotusCopy.Status.Phase + if prev != phase { + now := metav1.Now() + switch phase { + case lotusv1beta1.LotusPreparing: + lotusCopy.Status.PreparerStartTime = &now + case lotusv1beta1.LotusRunning: + lotusCopy.Status.WorkerStartTime = &now + case lotusv1beta1.LotusCleaning: + fallthrough + case lotusv1beta1.LotusFailureCleaning: + lotusCopy.Status.CleanerStartTime = &now + } + switch prev { + case lotusv1beta1.LotusPreparing: + lotusCopy.Status.PreparerCompletionTime = &now + case lotusv1beta1.LotusRunning: + lotusCopy.Status.WorkerCompletionTime = &now + case lotusv1beta1.LotusCleaning: + fallthrough + case lotusv1beta1.LotusFailureCleaning: + lotusCopy.Status.CleanerCompletionTime = &now + } + } + lotusCopy.Status.Phase = phase + return lotusCopy +} + +func (c *Controller) ensureStaticResources() error { + controllerDeployment, err := c.kubeClient.GetDeployment("lotus-controller", c.namespace) + if err != nil { + c.logger.Error("failed to get controller deployment", zap.Error(err)) + return err + } + owners := []metav1.OwnerReference{ + *metav1.NewControllerRef(controllerDeployment, schema.GroupVersionKind{ + Group: appsv1.SchemeGroupVersion.Group, + Version: appsv1.SchemeGroupVersion.Version, + Kind: "Deployment", + }), + } + + f := resource.NewStaticResourceFactory(c.namespace, c.release, c.configFile, owners) + thanosPeerService, err := f.NewThanosPeerService() + if err != nil { + return err + } + if err := c.kubeClient.ApplyService(f.ThanosPeerName(), c.namespace, thanosPeerService); err != nil { + return err + } + + cfg, err := config.FromFile(c.configFile) + if err != nil { + return err + } + if cfg.TimeSeriesStorage != nil { + timeSeriesStoreSecret, err := f.NewTimeSeriesStoreConfigSecret() + if err != nil { + return err + } + if err := c.kubeClient.ApplySecret(f.TimeSeriesStoreConfigSecretName(), c.namespace, timeSeriesStoreSecret); err != nil { + return err + } + thanosStore, err := f.NewThanosStoreStatefulSet() + if err != nil { + return err + } + if err := c.kubeClient.ApplyStatefulSet(f.ThanosStoreName(), c.namespace, thanosStore); err != nil { + return err + } + } + + thanosQueryDeployment, err := f.NewThanosQueryDeployment() + if err != nil { + return err + } + if err := c.kubeClient.ApplyDeployment(f.ThanosQueryName(), c.namespace, thanosQueryDeployment); err != nil { + return err + } + thanosQueryService, err := f.NewThanosQueryService() + if err != nil { + return err + } + return c.kubeClient.ApplyService(f.ThanosQueryName(), c.namespace, thanosQueryService) +} diff --git a/pkg/app/lotus/controller/controller_test.go b/pkg/app/lotus/controller/controller_test.go new file mode 100644 index 0000000..b0b429f --- /dev/null +++ b/pkg/app/lotus/controller/controller_test.go @@ -0,0 +1 @@ +package controller diff --git a/pkg/app/lotus/datasource/BUILD.bazel b/pkg/app/lotus/datasource/BUILD.bazel new file mode 100644 index 0000000..cbf3329 --- /dev/null +++ b/pkg/app/lotus/datasource/BUILD.bazel @@ -0,0 +1,13 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["datasource.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/datasource", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/model:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/lotus/datasource/datasource.go b/pkg/app/lotus/datasource/datasource.go new file mode 100644 index 0000000..57cd007 --- /dev/null +++ b/pkg/app/lotus/datasource/datasource.go @@ -0,0 +1,56 @@ +package datasource + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/model" +) + +type Builder interface { + Build(ds *config.DataSource, opts BuildOptions) (DataSource, error) +} + +type BuildOptions struct { + Logger *zap.Logger +} + +func (o BuildOptions) NamedLogger(name string) *zap.Logger { + if o.Logger != nil { + return o.Logger.Named(name) + } + return zap.NewNop().Named(name) +} + +type DataSource interface { + Querier + Checker +} + +type Querier interface { + Query(ctx context.Context, query string, ts time.Time) ([]*Sample, error) + CollectSummary(ctx context.Context, ts time.Time) (*model.MetricsSummary, error) +} + +type Sample struct { + Labels map[string]string + Value float64 + Timestamp time.Time +} + +type Checker interface { + Check(ctx context.Context, checks []Check) (*CheckResult, error) +} + +type Check struct { + Name string + Expr string + For string +} + +type CheckResult struct { + Actives []string +} diff --git a/pkg/app/lotus/datasource/prometheus/BUILD.bazel b/pkg/app/lotus/datasource/prometheus/BUILD.bazel new file mode 100644 index 0000000..e945d80 --- /dev/null +++ b/pkg/app/lotus/datasource/prometheus/BUILD.bazel @@ -0,0 +1,30 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "builder.go", + "prometheus.go", + "query.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/datasource/prometheus", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/datasource:go_default_library", + "//pkg/app/lotus/model:go_default_library", + "//pkg/metrics/grpcmetrics:go_default_library", + "//pkg/metrics/httpmetrics:go_default_library", + "@com_github_prometheus_client_golang//api:go_default_library", + "@com_github_prometheus_client_golang//api/prometheus/v1:go_default_library", + "@com_github_prometheus_common//model:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["prometheus_test.go"], + embed = [":go_default_library"], +) diff --git a/pkg/app/lotus/datasource/prometheus/builder.go b/pkg/app/lotus/datasource/prometheus/builder.go new file mode 100644 index 0000000..efa0485 --- /dev/null +++ b/pkg/app/lotus/datasource/prometheus/builder.go @@ -0,0 +1,35 @@ +package prometheus + +import ( + "fmt" + + promapi "github.com/prometheus/client_golang/api" + promv1 "github.com/prometheus/client_golang/api/prometheus/v1" + + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/datasource" +) + +type builder struct { +} + +func NewBuilder() datasource.Builder { + return &builder{} +} + +func (b *builder) Build(ds *config.DataSource, opts datasource.BuildOptions) (datasource.DataSource, error) { + configs, ok := ds.Type.(*config.DataSource_Prometheus) + if !ok { + return nil, fmt.Errorf("wrong datasource type for prometheus: %T", ds.Type) + } + cli, err := promapi.NewClient(promapi.Config{ + Address: configs.Prometheus.Address, + }) + if err != nil { + return nil, err + } + return &prometheus{ + api: promv1.NewAPI(cli), + logger: opts.NamedLogger("prometheus-datasource"), + }, nil +} diff --git a/pkg/app/lotus/datasource/prometheus/prometheus.go b/pkg/app/lotus/datasource/prometheus/prometheus.go new file mode 100644 index 0000000..1ee7523 --- /dev/null +++ b/pkg/app/lotus/datasource/prometheus/prometheus.go @@ -0,0 +1,288 @@ +package prometheus + +import ( + "context" + "fmt" + "strings" + "time" + + promv1 "github.com/prometheus/client_golang/api/prometheus/v1" + prommodel "github.com/prometheus/common/model" + "go.uber.org/zap" + + "github.com/nghialv/lotus/pkg/app/lotus/datasource" + "github.com/nghialv/lotus/pkg/app/lotus/model" + "github.com/nghialv/lotus/pkg/metrics/grpcmetrics" + "github.com/nghialv/lotus/pkg/metrics/httpmetrics" +) + +const ( + AlertNameLabel = prommodel.AlertNameLabel + AlertStateLabel = "alertstate" + AlertStateFiring = "firing" +) + +type prometheus struct { + api promv1.API + logger *zap.Logger +} + +func (p *prometheus) Query(ctx context.Context, query string, ts time.Time) ([]*datasource.Sample, error) { + v, err := p.api.Query(ctx, query, ts) + if err != nil { + return nil, err + } + vector, ok := v.(prommodel.Vector) + if !ok { + return nil, fmt.Errorf("unsupported value type: %s, %v", v.Type(), v) + } + return vectorToSamples(vector), nil +} + +func vectorToSamples(vector prommodel.Vector) []*datasource.Sample { + samples := make([]*datasource.Sample, 0, len(vector)) + for _, s := range vector { + sample := &datasource.Sample{ + Labels: make(map[string]string, len(s.Metric)), + } + for k, v := range s.Metric { + sample.Labels[string(k)] = string(v) + } + sample.Value = float64(s.Value) + sample.Timestamp = s.Timestamp.Time() + samples = append(samples, sample) + } + return samples +} + +func (p *prometheus) Check(ctx context.Context, checks []datasource.Check) (*datasource.CheckResult, error) { + var ts time.Time + v, err := p.api.Query(ctx, "ALERTS", ts) + if err != nil { + p.logger.Error("failed to run query to get alerts", zap.Error(err)) + return nil, err + } + vector, ok := v.(prommodel.Vector) + if !ok { + p.logger.Error("unsupported value type", zap.Any("value", v)) + return nil, fmt.Errorf("unsupported value type: %s", v.Type()) + } + p.logger.Debug("extracting actives", zap.Any("vector", vector), zap.Any("checks", checks)) + return &datasource.CheckResult{ + Actives: extractActives(vector, checks), + }, nil +} + +func extractActives(vector prommodel.Vector, checks []datasource.Check) []string { + targets := make(map[string]struct{}, len(checks)) + for _, check := range checks { + targets[check.Name] = struct{}{} + } + actives := make(map[string]struct{}) + for _, sample := range vector { + if sample.Value == 0 { + continue + } + name, ok := sample.Metric[AlertNameLabel] + if !ok { + continue + } + if _, ok := targets[string(name)]; !ok { + continue + } + state, ok := sample.Metric[AlertStateLabel] + if !ok { + continue + } + if state != AlertStateFiring { + continue + } + actives[string(name)] = struct{}{} + } + list := make([]string, 0, len(actives)) + for k, _ := range actives { + list = append(list, k) + } + return list +} + +func (p *prometheus) CollectSummary(ctx context.Context, ts time.Time) (*model.MetricsSummary, error) { + grpcByMethod, err := p.collectGRPCByMethod(ctx, ts) + if err != nil { + return nil, err + } + httpByPath, err := p.collectHTTPByPath(ctx, ts) + if err != nil { + return nil, err + } + grpcAll, err := p.multiQuery(ctx, map[string]string{ + model.GRPCRPCsKey: grpcRPCTotalQuery, + model.GRPCFailurePercentageKey: grpcFailurePercentageQuery, + model.GRPCLatencyAvgKey: grpcLatencyAvgQuery, + model.GRPCSentBytesAvgKey: grpcSentBytesAvgQuery, + model.GRPCReceivedBytesAvgKey: grpcReceivedBytesAvgQuery, + }, ts) + if err != nil { + return nil, err + } + httpAll, err := p.multiQuery(ctx, map[string]string{ + model.HTTPRequestsKey: httpRequestTotalQuery, + model.HTTPFailurePercentageKey: httpFailurePercentageQuery, + model.HTTPLatencyAvgKey: httpLatencyAvgQuery, + model.HTTPSentBytesAvgKey: httpSentBytesAvgQuery, + model.HTTPReceivedBytesAvgKey: httpReceivedBytesAvgQuery, + }, ts) + if err != nil { + return nil, err + } + summary := &model.MetricsSummary{ + GRPCRPCTotal: grpcAll[model.GRPCRPCsKey], + GRPCFailurePercentage: grpcAll[model.GRPCFailurePercentageKey], + GRPCAll: grpcAll, + GRPCByMethod: grpcByMethod, + HTTPRequestTotal: httpAll[model.HTTPRequestsKey], + HTTPFailurePercentage: httpAll[model.HTTPFailurePercentageKey], + HTTPAll: httpAll, + HTTPByPath: httpByPath, + } + queries := []struct { + Query string + Target *float64 + }{ + { + Query: vuStartedTotalQuery, + Target: &summary.VirtualUserStartedTotal, + }, + { + Query: vuFailedTotalQuery, + Target: &summary.VirtualUserFailedTotal, + }, + } + for i := range queries { + value, err := p.queryOne(ctx, queries[i].Query, ts) + if err != nil { + return nil, err + } + *queries[i].Target = value + } + return summary, nil +} + +func (p *prometheus) collectGRPCByMethod(ctx context.Context, ts time.Time) (map[string]model.ValueByLabel, error) { + result := make(map[string]model.ValueByLabel) + queries := map[string]string{ + model.GRPCRPCsKey: grpcRPCsByMethodQuery, + model.GRPCFailurePercentageKey: grpcFailurePercentageByMethodQuery, + model.GRPCLatencyAvgKey: grpcLatencyAvgByMethodQuery, + model.GRPCSentBytesAvgKey: grpcSentBytesAvgByMethodQuery, + model.GRPCReceivedBytesAvgKey: grpcReceivedBytesAvgByMethodQuery, + } + for name, query := range queries { + values, err := p.queryByLabel( + ctx, + func(labels map[string]string) (string, bool) { + value, ok := labels[grpcmetrics.KeyClientMethod.Name()] + return value, ok + }, + query, + ts, + ) + if err != nil { + return nil, err + } + for lv, v := range values { + if _, ok := result[lv]; !ok { + result[lv] = make(map[string]float64) + } + result[lv][name] = v + } + } + return result, nil +} + +func (p *prometheus) collectHTTPByPath(ctx context.Context, ts time.Time) (map[string]model.ValueByLabel, error) { + result := make(map[string]model.ValueByLabel) + queries := map[string]string{ + model.HTTPRequestsKey: httpRequestsByPathQuery, + model.HTTPFailurePercentageKey: httpFailurePercentageByPathQuery, + model.HTTPLatencyAvgKey: httpLatencyAvgByPathQuery, + model.HTTPSentBytesAvgKey: httpSentBytesAvgByPathQuery, + model.HTTPReceivedBytesAvgKey: httpReceivedBytesAvgByPathQuery, + } + for name, query := range queries { + values, err := p.queryByLabel( + ctx, + func(labels map[string]string) (string, bool) { + host, ok := labels[httpmetrics.KeyClientHost.Name()] + if !ok { + return "", false + } + route, ok := labels[httpmetrics.KeyClientRoute.Name()] + if !ok { + return "", false + } + method, ok := labels[httpmetrics.KeyClientMethod.Name()] + if !ok { + return "", false + } + return fmt.Sprintf("%s/%s/%s", method, host, strings.TrimLeft(route, "/")), true + }, + query, + ts, + ) + if err != nil { + return nil, err + } + for lv, v := range values { + if _, ok := result[lv]; !ok { + result[lv] = make(map[string]float64) + } + result[lv][name] = v + } + } + return result, nil +} + +func (p *prometheus) queryOne(ctx context.Context, query string, ts time.Time) (float64, error) { + samples, err := p.Query(ctx, query, ts) + if err != nil { + return 0, err + } + if len(samples) > 1 { + return 0, fmt.Errorf("response must contain only one sample") + } + if len(samples) == 0 { + return model.NoDataValue, nil + } + return samples[0].Value, nil +} + +type labelsToKey func(labels map[string]string) (string, bool) + +func (p *prometheus) queryByLabel(ctx context.Context, toKey labelsToKey, query string, ts time.Time) (map[string]float64, error) { + values := make(map[string]float64) + samples, err := p.Query(ctx, query, ts) + if err != nil { + return nil, err + } + for _, sample := range samples { + key, ok := toKey(sample.Labels) + if !ok { + continue + } + values[key] = sample.Value + } + return values, nil +} + +func (p *prometheus) multiQuery(ctx context.Context, queries map[string]string, ts time.Time) (map[string]float64, error) { + values := make(map[string]float64, len(queries)) + for key, query := range queries { + value, err := p.queryOne(ctx, query, ts) + if err != nil { + return nil, err + } + values[key] = value + } + return values, nil +} diff --git a/pkg/app/lotus/datasource/prometheus/prometheus_test.go b/pkg/app/lotus/datasource/prometheus/prometheus_test.go new file mode 100644 index 0000000..9aae1bd --- /dev/null +++ b/pkg/app/lotus/datasource/prometheus/prometheus_test.go @@ -0,0 +1,11 @@ +package prometheus + +import "testing" + +func TestVectorToSamples(t *testing.T) { + +} + +func TestExtractActives(t *testing.T) { + +} diff --git a/pkg/app/lotus/datasource/prometheus/query.go b/pkg/app/lotus/datasource/prometheus/query.go new file mode 100644 index 0000000..64b7cec --- /dev/null +++ b/pkg/app/lotus/datasource/prometheus/query.go @@ -0,0 +1,70 @@ +package prometheus + +const ( + // VirtualUser Queries + vuStartedTotalQuery = `sum(max_over_time(lotus_virtual_user_count{virtual_user_status="started"}[1h]))` + + vuFailedTotalQuery = `sum(max_over_time(lotus_virtual_user_count{virtual_user_status="failed"}[1h]))` + + // GRPC Queries + grpcRPCTotalQuery = `sum(max_over_time(lotus_grpc_client_completed_rpcs[1h]))` + + grpcFailurePercentageQuery = `100 * sum(max_over_time(lotus_grpc_client_completed_rpcs{grpc_client_status!~"OK|NOT_FOUND"}[1h])) / + sum(max_over_time(lotus_grpc_client_completed_rpcs[1h]))` + + grpcLatencyAvgQuery = `sum(max_over_time(lotus_grpc_client_roundtrip_latency_sum[1h])) / + sum(max_over_time(lotus_grpc_client_roundtrip_latency_count[1h]))` + + grpcSentBytesAvgQuery = `sum(max_over_time(lotus_grpc_client_sent_bytes_per_rpc_sum[1h])) / + sum(max_over_time(lotus_grpc_client_sent_bytes_per_rpc_count[1h]))` + + grpcReceivedBytesAvgQuery = `sum(max_over_time(lotus_grpc_client_received_bytes_per_rpc_sum[1h])) / + sum(max_over_time(lotus_grpc_client_received_bytes_per_rpc_count[1h]))` + + // GRPCByMethod Queries + grpcMethodLabel = "grpc_client_method" + + grpcRPCsByMethodQuery = `sum by(grpc_client_method) (max_over_time(lotus_grpc_client_completed_rpcs[1h]))` + + grpcFailurePercentageByMethodQuery = `100 * sum by(grpc_client_method) (max_over_time(lotus_grpc_client_completed_rpcs{grpc_client_status!~"OK|NOT_FOUND"}[1h])) / + sum by(grpc_client_method) (max_over_time(lotus_grpc_client_completed_rpcs[1h]))` + + grpcLatencyAvgByMethodQuery = `sum by(grpc_client_method) (max_over_time(lotus_grpc_client_roundtrip_latency_sum[1h])) / + sum by(grpc_client_method) (max_over_time(lotus_grpc_client_roundtrip_latency_count[1h]))` + + grpcSentBytesAvgByMethodQuery = `sum by(grpc_client_method) (max_over_time(lotus_grpc_client_sent_bytes_per_rpc_sum[1h])) / + sum by(grpc_client_method) (max_over_time(lotus_grpc_client_sent_bytes_per_rpc_count[1h]))` + + grpcReceivedBytesAvgByMethodQuery = `sum by(grpc_client_method) (max_over_time(lotus_grpc_client_received_bytes_per_rpc_sum[1h])) / + sum by(grpc_client_method) (max_over_time(lotus_grpc_client_received_bytes_per_rpc_count[1h]))` + + // HTTP Queries + httpRequestTotalQuery = `sum(max_over_time(lotus_http_client_completed_count[1h]))` + + httpFailurePercentageQuery = `100 * sum(max_over_time(lotus_http_client_completed_count{http_client_status=~"5.."}[1h])) / + sum(max_over_time(lotus_http_client_completed_count[1h]))` + + httpLatencyAvgQuery = `sum(max_over_time(lotus_http_client_roundtrip_latency_sum[1h])) / + sum(max_over_time(lotus_http_client_roundtrip_latency_count[1h]))` + + httpSentBytesAvgQuery = `sum(max_over_time(lotus_http_client_sent_bytes_sum[1h])) / + sum(max_over_time(lotus_http_client_sent_bytes_count[1h]))` + + httpReceivedBytesAvgQuery = `sum(max_over_time(lotus_http_client_received_bytes_sum[1h])) / + sum(max_over_time(lotus_http_client_received_bytes_count[1h]))` + + // HTTPByPath Queries + httpRequestsByPathQuery = `sum by(http_client_host,http_client_route,http_client_method) (max_over_time(lotus_http_client_completed_count[1h]))` + + httpFailurePercentageByPathQuery = `100 * sum by(http_client_host,http_client_route,http_client_method) (max_over_time(lotus_http_client_completed_count{http_client_status=~"5.."}[1h])) / + sum by(http_client_host,http_client_route,http_client_method) (max_over_time(lotus_http_client_completed_count[1h]))` + + httpLatencyAvgByPathQuery = `sum by(http_client_host,http_client_route,http_client_method) (max_over_time(lotus_http_client_roundtrip_latency_sum[1h])) / + sum by(http_client_host,http_client_route,http_client_method) (max_over_time(lotus_http_client_roundtrip_latency_count[1h]))` + + httpSentBytesAvgByPathQuery = `sum by(http_client_host,http_client_route,http_client_method) (max_over_time(lotus_http_client_sent_bytes_sum[1h])) / + sum by(http_client_host,http_client_route,http_client_method) (max_over_time(lotus_http_client_sent_bytes_count[1h]))` + + httpReceivedBytesAvgByPathQuery = `sum by(http_client_host,http_client_route,http_client_method) (max_over_time(lotus_http_client_received_bytes_sum[1h])) / + sum by(http_client_host,http_client_route,http_client_method) (max_over_time(lotus_http_client_received_bytes_count[1h]))` +) diff --git a/pkg/app/lotus/datasource/registry/BUILD.bazel b/pkg/app/lotus/datasource/registry/BUILD.bazel new file mode 100644 index 0000000..f217796 --- /dev/null +++ b/pkg/app/lotus/datasource/registry/BUILD.bazel @@ -0,0 +1,13 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["registry.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/datasource/registry", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/datasource:go_default_library", + "//pkg/app/lotus/datasource/prometheus:go_default_library", + ], +) diff --git a/pkg/app/lotus/datasource/registry/registry.go b/pkg/app/lotus/datasource/registry/registry.go new file mode 100644 index 0000000..3cb1cb3 --- /dev/null +++ b/pkg/app/lotus/datasource/registry/registry.go @@ -0,0 +1,43 @@ +package registry + +import ( + "fmt" + + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/datasource" + "github.com/nghialv/lotus/pkg/app/lotus/datasource/prometheus" +) + +var defaultRegistry = New() + +func init() { + defaultRegistry.Register(config.DataSource_PROMETHEUS, prometheus.NewBuilder()) +} + +func Default() *registry { + return defaultRegistry +} + +type registry struct { + builders map[config.DataSource_Type]datasource.Builder +} + +func New() *registry { + return ®istry{ + builders: make(map[config.DataSource_Type]datasource.Builder), + } +} + +func (r *registry) Register(dst config.DataSource_Type, b datasource.Builder) { + if r.builders[dst] != nil { + panic(fmt.Sprintf("duplicate builder registered: %v", dst)) + } + r.builders[dst] = b +} + +func (r *registry) Get(dst config.DataSource_Type) (datasource.Builder, error) { + if b, ok := r.builders[dst]; ok { + return b, nil + } + return nil, fmt.Errorf("unknown builder: %v", dst) +} diff --git a/pkg/app/lotus/kubeclient/BUILD.bazel b/pkg/app/lotus/kubeclient/BUILD.bazel new file mode 100644 index 0000000..6d22d59 --- /dev/null +++ b/pkg/app/lotus/kubeclient/BUILD.bazel @@ -0,0 +1,24 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["kubeclient.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/kubeclient", + visibility = ["//visibility:public"], + deps = [ + "@io_k8s_api//apps/v1:go_default_library", + "@io_k8s_api//batch/v1:go_default_library", + "@io_k8s_api//core/v1:go_default_library", + "@io_k8s_apimachinery//pkg/api/errors:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_client_go//kubernetes:go_default_library", + "@io_k8s_client_go//listers/batch/v1:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["kubeclient_test.go"], + embed = [":go_default_library"], +) diff --git a/pkg/app/lotus/kubeclient/kubeclient.go b/pkg/app/lotus/kubeclient/kubeclient.go new file mode 100644 index 0000000..d9425eb --- /dev/null +++ b/pkg/app/lotus/kubeclient/kubeclient.go @@ -0,0 +1,158 @@ +package kubeclient + +import ( + appsv1 "k8s.io/api/apps/v1" + "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + batchlisters "k8s.io/client-go/listers/batch/v1" +) + +type KubeClient interface { + EnsureDeployment(name, namespace string, factory func() (*appsv1.Deployment, error)) (*appsv1.Deployment, error) + EnsurePod(name, namespace string, factory func() (*corev1.Pod, error)) (*corev1.Pod, error) + EnsureService(name, namespace string, factory func() (*corev1.Service, error)) (*corev1.Service, error) + EnsureConfigMap(name, namespace string, factory func() (*corev1.ConfigMap, error)) (*corev1.ConfigMap, error) + EnsureJob(name, namespace string, factory func() (*v1.Job, error)) (*v1.Job, error) + + ApplyStatefulSet(name, namespace string, s *appsv1.StatefulSet) error + ApplyService(name, namespace string, s *corev1.Service) error + ApplyDeployment(name, namespace string, d *appsv1.Deployment) error + ApplySecret(name, namespace string, s *corev1.Secret) error + GetDeployment(name, namespace string) (*appsv1.Deployment, error) + DeleteDeployment(name, namespace string) error +} + +func New(kubeClientSet kubernetes.Interface, jobsLister batchlisters.JobLister) KubeClient { + return &kubeclient{ + kubeClientSet: kubeClientSet, + jobsLister: jobsLister, + } +} + +type kubeclient struct { + kubeClientSet kubernetes.Interface + jobsLister batchlisters.JobLister +} + +func (c *kubeclient) EnsureDeployment(name, namespace string, factory func() (*appsv1.Deployment, error)) (*appsv1.Deployment, error) { + deployment, err := c.kubeClientSet.AppsV1().Deployments(namespace).Get(name, metav1.GetOptions{}) + if !errors.IsNotFound(err) { + return deployment, err + } + deployment, err = factory() + if err != nil { + return nil, err + } + return c.kubeClientSet.AppsV1().Deployments(namespace).Create(deployment) +} + +func (c *kubeclient) EnsurePod(name, namespace string, factory func() (*corev1.Pod, error)) (*corev1.Pod, error) { + pod, err := c.kubeClientSet.CoreV1().Pods(namespace).Get(name, metav1.GetOptions{}) + if !errors.IsNotFound(err) { + return pod, err + } + pod, err = factory() + if err != nil { + return nil, err + } + return c.kubeClientSet.CoreV1().Pods(namespace).Create(pod) +} + +func (c *kubeclient) EnsureService(name, namespace string, factory func() (*corev1.Service, error)) (*corev1.Service, error) { + service, err := c.kubeClientSet.CoreV1().Services(namespace).Get(name, metav1.GetOptions{}) + if !errors.IsNotFound(err) { + return service, err + } + service, err = factory() + if err != nil { + return nil, err + } + return c.kubeClientSet.CoreV1().Services(namespace).Create(service) +} + +func (c *kubeclient) EnsureConfigMap(name, namespace string, factory func() (*corev1.ConfigMap, error)) (*corev1.ConfigMap, error) { + configmap, err := c.kubeClientSet.CoreV1().ConfigMaps(namespace).Get(name, metav1.GetOptions{}) + if !errors.IsNotFound(err) { + return configmap, err + } + configmap, err = factory() + if err != nil { + return nil, err + } + return c.kubeClientSet.CoreV1().ConfigMaps(namespace).Create(configmap) +} + +func (c *kubeclient) EnsureJob(name, namespace string, factory func() (*v1.Job, error)) (*v1.Job, error) { + job, err := c.jobsLister.Jobs(namespace).Get(name) + if !errors.IsNotFound(err) { + return job, err + } + job, err = factory() + if err != nil { + return nil, err + } + return c.kubeClientSet.BatchV1().Jobs(namespace).Create(job) +} + +func (c *kubeclient) ApplyStatefulSet(name, namespace string, s *appsv1.StatefulSet) error { + _, err := c.kubeClientSet.AppsV1().StatefulSets(namespace).Get(name, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = c.kubeClientSet.AppsV1().StatefulSets(namespace).Create(s) + return err + } + if err == nil { + _, err = c.kubeClientSet.AppsV1().StatefulSets(namespace).Update(s) + } + return err +} + +func (c *kubeclient) ApplyService(name, namespace string, s *corev1.Service) error { + _, err := c.kubeClientSet.CoreV1().Services(namespace).Get(name, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = c.kubeClientSet.CoreV1().Services(namespace).Create(s) + return err + } + if err == nil { + _, err = c.kubeClientSet.CoreV1().Services(namespace).Update(s) + } + return err +} + +func (c *kubeclient) ApplyDeployment(name, namespace string, d *appsv1.Deployment) error { + _, err := c.kubeClientSet.AppsV1().Deployments(namespace).Get(name, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = c.kubeClientSet.AppsV1().Deployments(namespace).Create(d) + return err + } + if err == nil { + _, err = c.kubeClientSet.AppsV1().Deployments(namespace).Update(d) + } + return err +} + +func (c *kubeclient) ApplySecret(name, namespace string, s *corev1.Secret) error { + _, err := c.kubeClientSet.CoreV1().Secrets(namespace).Get(name, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = c.kubeClientSet.CoreV1().Secrets(namespace).Create(s) + return err + } + if err == nil { + _, err = c.kubeClientSet.CoreV1().Secrets(namespace).Update(s) + } + return err +} + +func (c *kubeclient) GetDeployment(name, namespace string) (*appsv1.Deployment, error) { + return c.kubeClientSet.AppsV1().Deployments(namespace).Get(name, metav1.GetOptions{}) +} + +func (c *kubeclient) DeleteDeployment(name, namespace string) error { + err := c.kubeClientSet.AppsV1().Deployments(namespace).Delete(name, nil) + if err == nil || errors.IsNotFound(err) { + return nil + } + return err +} diff --git a/pkg/app/lotus/kubeclient/kubeclient_test.go b/pkg/app/lotus/kubeclient/kubeclient_test.go new file mode 100644 index 0000000..300b454 --- /dev/null +++ b/pkg/app/lotus/kubeclient/kubeclient_test.go @@ -0,0 +1 @@ +package kubeclient diff --git a/pkg/app/lotus/model/BUILD.bazel b/pkg/app/lotus/model/BUILD.bazel new file mode 100644 index 0000000..2ec5838 --- /dev/null +++ b/pkg/app/lotus/model/BUILD.bazel @@ -0,0 +1,29 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "lotus.go", + "metrics_summary.go", + "render.go", + "result.go", + "templates.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/model", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["render_test.go"], + embed = [":go_default_library"], + deps = [ + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/pkg/app/lotus/model/lotus.go b/pkg/app/lotus/model/lotus.go new file mode 100644 index 0000000..56f593a --- /dev/null +++ b/pkg/app/lotus/model/lotus.go @@ -0,0 +1,18 @@ +package model + +import ( + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + LotusKind = "Lotus" +) + +var ( + ControllerKind = schema.GroupVersionKind{ + Group: lotusv1beta1.SchemeGroupVersion.Group, + Version: lotusv1beta1.SchemeGroupVersion.Version, + Kind: LotusKind, + } +) diff --git a/pkg/app/lotus/model/metrics_summary.go b/pkg/app/lotus/model/metrics_summary.go new file mode 100644 index 0000000..291aef0 --- /dev/null +++ b/pkg/app/lotus/model/metrics_summary.go @@ -0,0 +1,36 @@ +package model + +const ( + NoDataValue float64 = -1 +) + +type MetricsSummary struct { + GRPCRPCTotal float64 + GRPCFailurePercentage float64 + GRPCAll ValueByLabel + GRPCByMethod map[string]ValueByLabel + + HTTPRequestTotal float64 + HTTPFailurePercentage float64 + HTTPAll ValueByLabel + HTTPByPath map[string]ValueByLabel + + VirtualUserStartedTotal float64 + VirtualUserFailedTotal float64 +} + +type ValueByLabel map[string]float64 + +const ( + GRPCRPCsKey = "RPCs" + GRPCFailurePercentageKey = "FailurePercentage" + GRPCLatencyAvgKey = "LatencyAvg" + GRPCSentBytesAvgKey = "SentBytesAvg" + GRPCReceivedBytesAvgKey = "ReceivedBytesAvg" + + HTTPRequestsKey = "Requests" + HTTPFailurePercentageKey = "FailurePercentage" + HTTPLatencyAvgKey = "LatencyAvg" + HTTPSentBytesAvgKey = "SentBytesAvg" + HTTPReceivedBytesAvgKey = "ReceivedBytesAvg" +) diff --git a/pkg/app/lotus/model/render.go b/pkg/app/lotus/model/render.go new file mode 100644 index 0000000..9887445 --- /dev/null +++ b/pkg/app/lotus/model/render.go @@ -0,0 +1,184 @@ +package model + +import ( + "bytes" + "fmt" + "math" + "text/template" + "time" +) + +type RenderFormat string + +const ( + RenderFormatMarkdown RenderFormat = "markdown" + RenderFormatText = "text" + RenderFormatJson = "json" + + noDataMark = "--" + timeFormat = "15:04:05 2006-01-02" +) + +type valueByLabelList struct { + name string + values ValueByLabel +} + +func renderTemplate(result *Result, tpl string) ([]byte, error) { + funcMap := template.FuncMap{ + "formatValue": formatValue, + "formatTime": formatTime, + "formatGRPCByMethod": formatGRPCByMethod, + "formatHTTPByPath": formatHTTPByPath, + } + template, err := template.New("result").Funcs(funcMap).Parse(tpl) + if err != nil { + return nil, err + } + var buffer bytes.Buffer + err = template.Execute(&buffer, result) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func formatGRPCByMethod(data map[string]ValueByLabel, all ValueByLabel) string { + keys := []struct { + Desc string + Key string + }{ + {Desc: "RPCs", Key: GRPCRPCsKey}, + {Desc: `Failure%`, Key: GRPCFailurePercentageKey}, + {Desc: "Latency(ms)", Key: GRPCLatencyAvgKey}, + {Desc: "SentBytes", Key: GRPCSentBytesAvgKey}, + {Desc: "RecvBytes", Key: GRPCReceivedBytesAvgKey}, + } + methodMaxLength := 5 + for method, _ := range data { + if len(method) > methodMaxLength { + methodMaxLength = len(method) + } + } + var b bytes.Buffer + format := func(key string, values ValueByLabel) string { + value, ok := values[key] + if !ok { + value = NoDataValue + } + return formatValue(value) + } + titleFormat := fmt.Sprintf(" %%-%ds %%-8s %%-8s %%-12s %%-8s %%-8s\n\n", methodMaxLength) + detailFormat := fmt.Sprintf(" - %%-%ds %%-8s %%-8s %%-12s %%-8s %%-8s\n", methodMaxLength) + + b.WriteString(fmt.Sprintf(titleFormat, "", keys[0].Desc, keys[1].Desc, keys[2].Desc, keys[3].Desc, keys[4].Desc)) + rows := make([]valueByLabelList, 0, len(data)+1) + for method, values := range data { + rows = append(rows, valueByLabelList{ + name: method, + values: values, + }) + } + rows = append(rows, valueByLabelList{ + name: "all", + values: all, + }) + for _, row := range rows { + b.WriteString(fmt.Sprintf(detailFormat, + row.name, + format(keys[0].Key, row.values), + format(keys[1].Key, row.values), + format(keys[2].Key, row.values), + format(keys[3].Key, row.values), + format(keys[4].Key, row.values), + )) + } + return b.String() +} + +func formatHTTPByPath(data map[string]ValueByLabel, all ValueByLabel) string { + keys := []struct { + Desc string + Key string + }{ + {Desc: "Requests", Key: HTTPRequestsKey}, + {Desc: `Failure%`, Key: HTTPFailurePercentageKey}, + {Desc: "Latency(ms)", Key: HTTPLatencyAvgKey}, + {Desc: "SentBytes", Key: HTTPSentBytesAvgKey}, + {Desc: "RecvBytes", Key: HTTPReceivedBytesAvgKey}, + } + pathMaxLength := 5 + for path, _ := range data { + if len(path) > pathMaxLength { + pathMaxLength = len(path) + } + } + var b bytes.Buffer + format := func(key string, values ValueByLabel) string { + value, ok := values[key] + if !ok { + value = NoDataValue + } + return formatValue(value) + } + titleFormat := fmt.Sprintf(" %%-%ds %%-8s %%-8s %%-12s %%-8s %%-8s\n\n", pathMaxLength) + detailFormat := fmt.Sprintf(" - %%-%ds %%-8s %%-8s %%-12s %%-8s %%-8s\n", pathMaxLength) + + b.WriteString(fmt.Sprintf(titleFormat, "", keys[0].Desc, keys[1].Desc, keys[2].Desc, keys[3].Desc, keys[4].Desc)) + rows := make([]valueByLabelList, 0, len(data)+1) + for path, values := range data { + rows = append(rows, valueByLabelList{ + name: path, + values: values, + }) + } + rows = append(rows, valueByLabelList{ + name: "all", + values: all, + }) + for _, row := range rows { + b.WriteString(fmt.Sprintf(detailFormat, + row.name, + format(keys[0].Key, row.values), + format(keys[1].Key, row.values), + format(keys[2].Key, row.values), + format(keys[3].Key, row.values), + format(keys[4].Key, row.values), + )) + } + return b.String() +} + +// https://en.wikipedia.org/wiki/Metric_prefix +func formatValue(v float64) string { + if v == NoDataValue { + return noDataMark + } + if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return fmt.Sprintf("%.4g", v) + } + if math.Abs(v) >= 1 { + prefix := "" + for _, p := range []string{"k", "M", "G", "T", "P", "E", "Z", "Y"} { + if math.Abs(v) < 1000 { + break + } + prefix = p + v /= 1000 + } + return fmt.Sprintf("%.4g%s", v, prefix) + } + prefix := "" + for _, p := range []string{"m", "u", "n", "p", "f", "a", "z", "y"} { + if math.Abs(v) >= 1 { + break + } + prefix = p + v *= 1000 + } + return fmt.Sprintf("%.4g%s", v, prefix) +} + +func formatTime(t time.Time) string { + return t.Format(timeFormat) +} diff --git a/pkg/app/lotus/model/render_test.go b/pkg/app/lotus/model/render_test.go new file mode 100644 index 0000000..4f12062 --- /dev/null +++ b/pkg/app/lotus/model/render_test.go @@ -0,0 +1,99 @@ +package model + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRender(t *testing.T) { + metricsSummary := &MetricsSummary{ + GRPCRPCTotal: 25000000, + GRPCFailurePercentage: 2.507, + GRPCAll: map[string]float64{ + GRPCRPCsKey: 25000000, + GRPCFailurePercentageKey: 1.207, + GRPCLatencyAvgKey: 135, + GRPCSentBytesAvgKey: 12, + GRPCReceivedBytesAvgKey: 245, + }, + GRPCByMethod: map[string]ValueByLabel{ + "helloworld.Hello": map[string]float64{ + GRPCRPCsKey: 12500000, + GRPCFailurePercentageKey: 1.015, + GRPCLatencyAvgKey: 105, + GRPCSentBytesAvgKey: 15, + GRPCReceivedBytesAvgKey: 8, + }, + "helloworld.Profile": map[string]float64{ + GRPCRPCsKey: 12500000, + GRPCFailurePercentageKey: 1.415, + GRPCLatencyAvgKey: 152, + GRPCSentBytesAvgKey: 8, + GRPCReceivedBytesAvgKey: 256, + }, + }, + HTTPRequestTotal: 10, + HTTPFailurePercentage: 1.05890, + HTTPAll: map[string]float64{ + HTTPRequestsKey: 10, + HTTPFailurePercentageKey: 1.05890, + }, + VirtualUserStartedTotal: 1000000, + VirtualUserFailedTotal: 0, + } + testcases := []struct { + Result *Result + }{ + { + Result: &Result{ + TestID: "test-scenario-12345", + Status: TestSucceeded, + MetricsSummary: metricsSummary, + StartedTimestamp: time.Now().Add(-10 * time.Minute), + FinishedTimestamp: time.Now(), + }, + }, + } + for _, tc := range testcases { + tc.Result.SetGrafanaDashboardURLs("http://localhost:3000") + out, err := tc.Result.Render(RenderFormatText) + require.NoError(t, err) + fmt.Println(string(out)) + } +} + +func TestFormatValue(t *testing.T) { + testcases := []struct { + Value float64 + Expected string + }{ + { + Value: -1, + Expected: noDataMark, + }, + { + Value: 0.0, + Expected: "0", + }, + { + Value: 2.15, + Expected: "2.15", + }, + { + Value: 12345678, + Expected: "12.35M", + }, + { + Value: 0.123456, + Expected: "123.5m", + }, + } + for _, tc := range testcases { + out := formatValue(tc.Value) + assert.Equal(t, tc.Expected, out) + } +} diff --git a/pkg/app/lotus/model/result.go b/pkg/app/lotus/model/result.go new file mode 100644 index 0000000..d5055dc --- /dev/null +++ b/pkg/app/lotus/model/result.go @@ -0,0 +1,53 @@ +package model + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +type TestStatus string + +const ( + TestSucceeded TestStatus = "Succeeded" + TestFailed = "Failed" + TestCancelled = "Cancelled" +) + +type Result struct { + TestID string + Status TestStatus + MetricsSummary *MetricsSummary + FailureReason string + FailedChecks []string + StartedTimestamp time.Time + FinishedTimestamp time.Time + GrafanaGRPCDashboardsURL string + GrafanaHTTPDashboardsURL string +} + +func (r *Result) SetFailed(reason string) { + r.Status = TestFailed + r.FailureReason = reason +} + +func (r *Result) SetGrafanaDashboardURLs(base string) { + base = strings.TrimRight(base, "/") + var from int64 = r.StartedTimestamp.Add(-time.Minute).UnixNano() / 1e6 + var to int64 = r.FinishedTimestamp.Add(time.Minute).UnixNano() / 1e6 + r.GrafanaGRPCDashboardsURL = fmt.Sprintf("%s/dashboard/db/grpc?from=%d&to=%d&var-testId=%s", base, from, to, r.TestID) + r.GrafanaHTTPDashboardsURL = fmt.Sprintf("%s/dashboard/db/http?from=%d&to=%d&var-testId=%s", base, from, to, r.TestID) +} + +func (r *Result) Render(format RenderFormat) ([]byte, error) { + switch format { + case RenderFormatMarkdown: + return renderTemplate(r, markdownTemplate) + case RenderFormatText: + return renderTemplate(r, textTemplate) + case RenderFormatJson: + return json.Marshal(r) + } + return nil, fmt.Errorf("unsupported render format: %s", format) +} diff --git a/pkg/app/lotus/model/templates.go b/pkg/app/lotus/model/templates.go new file mode 100644 index 0000000..75f7be1 --- /dev/null +++ b/pkg/app/lotus/model/templates.go @@ -0,0 +1,48 @@ +package model + +const ( + textTemplate = ` +TestID: {{ .TestID }} +TestStatus: {{ .Status }} +{{- if eq .Status "Failed" }} + Reason: {{ .FailureReason }} +{{- if gt (len .FailedChecks) 0 }} + FailedChecks: {{ .FailedChecks }} +{{- end }} +{{- end }} +Start: {{ formatTime .StartedTimestamp }} +End: {{ formatTime .FinishedTimestamp }} + +MetricsSummary: + +{{- if .MetricsSummary }} + +1. Virtual User + - Started: {{ formatValue .MetricsSummary.VirtualUserStartedTotal }} + - Failed: {{ formatValue .MetricsSummary.VirtualUserFailedTotal }} + +2. GRPC + - RPCTotal: {{ formatValue .MetricsSummary.GRPCRPCTotal }} + - FailurePercentage: {{ formatValue .MetricsSummary.GRPCFailurePercentage }} + +GroupByMethod: +{{ formatGRPCByMethod .MetricsSummary.GRPCByMethod .MetricsSummary.GRPCAll }} +Grafana: {{ .GrafanaGRPCDashboardsURL }} + +3. HTTP + - RequestTotal: {{ formatValue .MetricsSummary.HTTPRequestTotal }} + - FailurePercentage: {{ formatValue .MetricsSummary.HTTPFailurePercentage }} + +GroupByPath: +{{ formatHTTPByPath .MetricsSummary.HTTPByPath .MetricsSummary.HTTPAll }} +Grafana: {{ .GrafanaHTTPDashboardsURL }} +{{- else }} + + No data +{{- end }} +` +) + +var ( + markdownTemplate = textTemplate +) diff --git a/pkg/app/lotus/reporter/BUILD.bazel b/pkg/app/lotus/reporter/BUILD.bazel new file mode 100644 index 0000000..b220246 --- /dev/null +++ b/pkg/app/lotus/reporter/BUILD.bazel @@ -0,0 +1,25 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["reporter.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/reporter", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/model:go_default_library", + "@org_golang_x_sync//errgroup:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["reporter_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/app/lotus/model:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + ], +) diff --git a/pkg/app/lotus/reporter/azure/BUILD.bazel b/pkg/app/lotus/reporter/azure/BUILD.bazel new file mode 100644 index 0000000..71aabd0 --- /dev/null +++ b/pkg/app/lotus/reporter/azure/BUILD.bazel @@ -0,0 +1,8 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["azure.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/reporter/azure", + visibility = ["//visibility:public"], +) diff --git a/pkg/app/lotus/reporter/azure/azure.go b/pkg/app/lotus/reporter/azure/azure.go new file mode 100644 index 0000000..fd6f983 --- /dev/null +++ b/pkg/app/lotus/reporter/azure/azure.go @@ -0,0 +1,3 @@ +package azure + +//TODO: implementation diff --git a/pkg/app/lotus/reporter/gcs/BUILD.bazel b/pkg/app/lotus/reporter/gcs/BUILD.bazel new file mode 100644 index 0000000..220eea3 --- /dev/null +++ b/pkg/app/lotus/reporter/gcs/BUILD.bazel @@ -0,0 +1,16 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["gcs.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/reporter/gcs", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/model:go_default_library", + "//pkg/app/lotus/reporter:go_default_library", + "@com_google_cloud_go//storage:go_default_library", + "@org_golang_google_api//option:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/lotus/reporter/gcs/gcs.go b/pkg/app/lotus/reporter/gcs/gcs.go new file mode 100644 index 0000000..5e9f4a1 --- /dev/null +++ b/pkg/app/lotus/reporter/gcs/gcs.go @@ -0,0 +1,95 @@ +package gcs + +import ( + "context" + "fmt" + + "cloud.google.com/go/storage" + "go.uber.org/zap" + "google.golang.org/api/option" + + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/model" + "github.com/nghialv/lotus/pkg/app/lotus/reporter" +) + +type builder struct { +} + +func NewBuilder() reporter.Builder { + return &builder{} +} + +func (b *builder) Build(r *config.Receiver, opts reporter.BuildOptions) (reporter.Reporter, error) { + configs, ok := r.Type.(*config.Receiver_Gcs) + if !ok { + return nil, fmt.Errorf("wrong receiver type for gcs: %T", r.Type) + } + return &gcs{ + bucket: configs.Gcs.Bucket, + credentialsFile: r.CredentialsFile(configs.Gcs.Credentials.File), + logger: opts.NamedLogger("gcs-reporter"), + }, nil +} + +type gcs struct { + bucket string + credentialsFile string + logger *zap.Logger +} + +func (g *gcs) Report(ctx context.Context, result *model.Result) (lastErr error) { + client, err := storage.NewClient(ctx, option.WithCredentialsFile(g.credentialsFile)) + if err != nil { + g.logger.Error("failed to create gcs storage client", zap.Error(err)) + lastErr = err + return + } + cases := []struct { + format model.RenderFormat + extension string + contentType string + }{ + { + format: model.RenderFormatText, + extension: "txt", + contentType: "text/plain", + }, + { + format: model.RenderFormatJson, + extension: "json", + contentType: "application/json", + }, + } + for _, c := range cases { + data, err := result.Render(c.format) + if err != nil { + g.logger.Error("failed to render result", zap.Error(err)) + lastErr = err + continue + } + filename := fmt.Sprintf("%s/%s.%s", result.TestID, result.TestID, c.extension) + g.logger.Info("writing test result to gcs storage", + zap.String("testID", result.TestID), + zap.String("filename", filename), + zap.String("bucket", g.bucket), + ) + wc := client.Bucket(g.bucket).Object(filename).NewWriter(ctx) + wc.ContentType = c.contentType + wc.ACL = []storage.ACLRule{{ + Entity: storage.AllUsers, + Role: storage.RoleReader, + }} + if _, err := wc.Write(data); err != nil { + g.logger.Error("failed to write result", zap.Error(err)) + lastErr = err + continue + } + if err := wc.Close(); err != nil { + g.logger.Error("failed to close writer", zap.Error(err)) + lastErr = err + continue + } + } + return +} diff --git a/pkg/app/lotus/reporter/logger/BUILD.bazel b/pkg/app/lotus/reporter/logger/BUILD.bazel new file mode 100644 index 0000000..266bc83 --- /dev/null +++ b/pkg/app/lotus/reporter/logger/BUILD.bazel @@ -0,0 +1,14 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["logger.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/reporter/logger", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/model:go_default_library", + "//pkg/app/lotus/reporter:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/lotus/reporter/logger/logger.go b/pkg/app/lotus/reporter/logger/logger.go new file mode 100644 index 0000000..af48cfc --- /dev/null +++ b/pkg/app/lotus/reporter/logger/logger.go @@ -0,0 +1,40 @@ +package logger + +import ( + "context" + "fmt" + + "go.uber.org/zap" + + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/model" + "github.com/nghialv/lotus/pkg/app/lotus/reporter" +) + +type builder struct { +} + +func NewBuilder() reporter.Builder { + return &builder{} +} + +func (b *builder) Build(r *config.Receiver, opts reporter.BuildOptions) (reporter.Reporter, error) { + return &logger{ + logger: opts.NamedLogger("logger-reporter"), + }, nil +} + +type logger struct { + logger *zap.Logger +} + +func (p *logger) Report(ctx context.Context, result *model.Result) error { + out, err := result.Render(model.RenderFormatText) + if err != nil { + return err + } + fmt.Println("=========== TEST RESULT ==========") + fmt.Println(string(out)) + fmt.Println("=========== END ==========") + return nil +} diff --git a/pkg/app/lotus/reporter/registry/BUILD.bazel b/pkg/app/lotus/reporter/registry/BUILD.bazel new file mode 100644 index 0000000..b7cce5c --- /dev/null +++ b/pkg/app/lotus/reporter/registry/BUILD.bazel @@ -0,0 +1,15 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["registry.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/reporter/registry", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/reporter:go_default_library", + "//pkg/app/lotus/reporter/gcs:go_default_library", + "//pkg/app/lotus/reporter/logger:go_default_library", + "//pkg/app/lotus/reporter/slack:go_default_library", + ], +) diff --git a/pkg/app/lotus/reporter/registry/registry.go b/pkg/app/lotus/reporter/registry/registry.go new file mode 100644 index 0000000..5d0d96c --- /dev/null +++ b/pkg/app/lotus/reporter/registry/registry.go @@ -0,0 +1,47 @@ +package registry + +import ( + "fmt" + + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/reporter" + "github.com/nghialv/lotus/pkg/app/lotus/reporter/gcs" + "github.com/nghialv/lotus/pkg/app/lotus/reporter/logger" + "github.com/nghialv/lotus/pkg/app/lotus/reporter/slack" +) + +var defaultRegistry = New() + +func init() { + defaultRegistry.Register(config.Receiver_LOGGER, logger.NewBuilder()) + defaultRegistry.Register(config.Receiver_GCS, gcs.NewBuilder()) + defaultRegistry.Register(config.Receiver_SLACK, slack.NewBuilder()) +} + +func Default() *registry { + return defaultRegistry +} + +type registry struct { + builders map[config.Receiver_Type]reporter.Builder +} + +func New() *registry { + return ®istry{ + builders: make(map[config.Receiver_Type]reporter.Builder), + } +} + +func (r *registry) Register(rt config.Receiver_Type, b reporter.Builder) { + if r.builders[rt] != nil { + panic(fmt.Sprintf("duplicate builder registered: %v", rt)) + } + r.builders[rt] = b +} + +func (r *registry) Get(rt config.Receiver_Type) (reporter.Builder, error) { + if b, ok := r.builders[rt]; ok { + return b, nil + } + return nil, fmt.Errorf("unknown builder: %v", rt) +} diff --git a/pkg/app/lotus/reporter/reporter.go b/pkg/app/lotus/reporter/reporter.go new file mode 100644 index 0000000..4f24b95 --- /dev/null +++ b/pkg/app/lotus/reporter/reporter.go @@ -0,0 +1,65 @@ +package reporter + +import ( + "context" + + "go.uber.org/zap" + "golang.org/x/sync/errgroup" + + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/model" +) + +type Builder interface { + Build(r *config.Receiver, opts BuildOptions) (Reporter, error) +} + +type BuildOptions struct { + Logger *zap.Logger +} + +func (o BuildOptions) NamedLogger(name string) *zap.Logger { + if o.Logger != nil { + return o.Logger.Named(name) + } + return zap.NewNop().Named(name) +} + +type Reporter interface { + Report(ctx context.Context, result *model.Result) error +} + +type multiReporter struct { + reporters []Reporter +} + +func MultiReporter(reporters ...Reporter) Reporter { + all := make([]Reporter, 0, len(reporters)) + for _, r := range reporters { + if mr, ok := r.(*multiReporter); ok { + all = append(all, mr.reporters...) + } else { + all = append(all, r) + } + } + return &multiReporter{ + reporters: all, + } +} + +func (mr *multiReporter) Report(ctx context.Context, result *model.Result) error { + if len(mr.reporters) == 0 { + return nil + } + if len(mr.reporters) == 1 { + return mr.reporters[0].Report(ctx, result) + } + g, ctx := errgroup.WithContext(ctx) + for i := range mr.reporters { + reporter := mr.reporters[i] + g.Go(func() error { + return reporter.Report(ctx, result) + }) + } + return g.Wait() +} diff --git a/pkg/app/lotus/reporter/reporter_test.go b/pkg/app/lotus/reporter/reporter_test.go new file mode 100644 index 0000000..f0245e0 --- /dev/null +++ b/pkg/app/lotus/reporter/reporter_test.go @@ -0,0 +1,66 @@ +package reporter + +import ( + "context" + "errors" + "testing" + + "github.com/nghialv/lotus/pkg/app/lotus/model" + "github.com/stretchr/testify/assert" +) + +type reporterFunc func(ctx context.Context, result *model.Result) error + +func (f reporterFunc) Report(ctx context.Context, result *model.Result) error { + return f(ctx, result) +} + +func TestMultiReporter(t *testing.T) { + var calls int + successReporter := reporterFunc(func(ctx context.Context, result *model.Result) error { + calls++ + return nil + }) + failureReporter := reporterFunc(func(ctx context.Context, result *model.Result) error { + calls++ + return errors.New("failed") + }) + testcases := []struct { + reporter Reporter + result *model.Result + hasError bool + calls int + }{ + { + reporter: MultiReporter(successReporter), + hasError: false, + calls: 1, + }, + { + reporter: MultiReporter(successReporter, successReporter), + hasError: false, + calls: 2, + }, + { + reporter: MultiReporter(successReporter, failureReporter), + hasError: true, + calls: 2, + }, + { + reporter: MultiReporter(successReporter, MultiReporter(successReporter, successReporter)), + hasError: false, + calls: 3, + }, + { + reporter: MultiReporter(successReporter, MultiReporter(successReporter, failureReporter)), + hasError: true, + calls: 3, + }, + } + for _, tc := range testcases { + calls = 0 + err := tc.reporter.Report(context.TODO(), tc.result) + assert.Equal(t, tc.hasError, err != nil) + assert.Equal(t, tc.calls, calls) + } +} diff --git a/pkg/app/lotus/reporter/s3/BUILD.bazel b/pkg/app/lotus/reporter/s3/BUILD.bazel new file mode 100644 index 0000000..c5a78b9 --- /dev/null +++ b/pkg/app/lotus/reporter/s3/BUILD.bazel @@ -0,0 +1,8 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["s3.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/reporter/s3", + visibility = ["//visibility:public"], +) diff --git a/pkg/app/lotus/reporter/s3/s3.go b/pkg/app/lotus/reporter/s3/s3.go new file mode 100644 index 0000000..d0ae1f7 --- /dev/null +++ b/pkg/app/lotus/reporter/s3/s3.go @@ -0,0 +1,3 @@ +package s3 + +// TODO: Implementation diff --git a/pkg/app/lotus/reporter/slack/BUILD.bazel b/pkg/app/lotus/reporter/slack/BUILD.bazel new file mode 100644 index 0000000..19dc612 --- /dev/null +++ b/pkg/app/lotus/reporter/slack/BUILD.bazel @@ -0,0 +1,14 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["slack.go"], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/reporter/slack", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/model:go_default_library", + "//pkg/app/lotus/reporter:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/lotus/reporter/slack/slack.go b/pkg/app/lotus/reporter/slack/slack.go new file mode 100644 index 0000000..58cb9fd --- /dev/null +++ b/pkg/app/lotus/reporter/slack/slack.go @@ -0,0 +1,101 @@ +package slack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + + "go.uber.org/zap" + + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/model" + "github.com/nghialv/lotus/pkg/app/lotus/reporter" +) + +type Message struct { + Text string `json:"text"` + UserName string `json:"username,omitempty"` + IconURL string `json:"icon_url,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + Attachments []*Attachment `json:"attachments,omitempty"` +} + +type Attachment struct { + Color string `json:"color,omitempty"` + Title string `json:"title,omitempty"` + TitleLink string `json:"title_link,omitempty"` + Text string `json:"text,omitempty"` + MarkdownIn []string `json:"mrkdwn_in,omitempty"` +} + +type builder struct { +} + +func NewBuilder() reporter.Builder { + return &builder{} +} + +func (b *builder) Build(r *config.Receiver, opts reporter.BuildOptions) (reporter.Reporter, error) { + configs, ok := r.Type.(*config.Receiver_Slack) + if !ok { + return nil, fmt.Errorf("wrong receiver type for slack: %T", r.Type) + } + return &slack{ + hookURL: configs.Slack.HookUrl, + client: http.DefaultClient, + logger: opts.NamedLogger("slack-reporter"), + }, nil +} + +type slack struct { + hookURL string + client *http.Client + logger *zap.Logger +} + +func (s *slack) Report(ctx context.Context, result *model.Result) error { + data, err := result.Render(model.RenderFormatMarkdown) + if err != nil { + return err + } + att := &Attachment{ + Title: fmt.Sprintf("%s %s", result.TestID, result.Status), + Text: fmt.Sprintf("```%s```", string(data)), + Color: "danger", + MarkdownIn: []string{ + "text", + }, + } + if result.Status == model.TestSucceeded { + att.Color = "good" + } + msg := &Message{ + Attachments: []*Attachment{att}, + } + if err := s.send(msg); err != nil { + s.logger.Error("failed to report to slack", zap.Error(err)) + return err + } + return nil +} + +func (s *slack) send(msg *Message) error { + buf, err := json.Marshal(msg) + if err != nil { + return err + } + resp, err := s.client.Post(s.hookURL, "application/json", bytes.NewReader(buf)) + if err != nil { + return err + } + defer resp.Body.Close() + io.Copy(ioutil.Discard, resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return nil +} diff --git a/pkg/app/lotus/resource/BUILD.bazel b/pkg/app/lotus/resource/BUILD.bazel new file mode 100644 index 0000000..f933a45 --- /dev/null +++ b/pkg/app/lotus/resource/BUILD.bazel @@ -0,0 +1,37 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "factory.go", + "job.go", + "prometheus.go", + "secret.go", + "static_factory.go", + "templates.go", + "thanos.go", + "worker.go", + ], + importpath = "github.com/nghialv/lotus/pkg/app/lotus/resource", + visibility = ["//visibility:public"], + deps = [ + "//pkg/app/lotus/apis/lotus/v1beta1:go_default_library", + "//pkg/app/lotus/config:go_default_library", + "//pkg/app/lotus/model:go_default_library", + "//pkg/version:go_default_library", + "@com_github_ghodss_yaml//:go_default_library", + "@io_k8s_api//apps/v1:go_default_library", + "@io_k8s_api//batch/v1:go_default_library", + "@io_k8s_api//core/v1:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/util/intstr:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["templates_test.go"], + embed = [":go_default_library"], + deps = ["@com_github_stretchr_testify//require:go_default_library"], +) diff --git a/pkg/app/lotus/resource/factory.go b/pkg/app/lotus/resource/factory.go new file mode 100644 index 0000000..9a94dfe --- /dev/null +++ b/pkg/app/lotus/resource/factory.go @@ -0,0 +1,159 @@ +package resource + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + "github.com/nghialv/lotus/pkg/app/lotus/config" + "github.com/nghialv/lotus/pkg/app/lotus/model" + "github.com/nghialv/lotus/pkg/version" +) + +var ( + thanosImage = "improbable/thanos:v0.2.0" + prometheusImage = "quay.io/prometheus/prometheus:v2.3.2" + lotusImage = fmt.Sprintf("nghialv2607/lotus:%s", version.Get().GitCommit) +) + +type ResourceFactory interface { + PreparerJobName() string + MonitorJobName() string + CleanerJobName() string + WorkerName() string + PrometheusName() string + + NewPreparerJob() (*batchv1.Job, error) + NewCleanerJob() (*batchv1.Job, error) + NewMonitorJob() (*batchv1.Job, error) + NewMonitorConfigMap() (*corev1.ConfigMap, error) + NewWorkerDeployment() (*appsv1.Deployment, error) + NewWorkerService() (*corev1.Service, error) + NewPrometheusPod(serviceAccountName, release string) (*corev1.Pod, error) + NewPrometheusService() (*corev1.Service, error) + NewPrometheusConfigMap() (*corev1.ConfigMap, error) +} + +type resourceFactory struct { + lotus *lotusv1beta1.Lotus + configFile string +} + +func NewFactory(lotus *lotusv1beta1.Lotus, configFile string) ResourceFactory { + return &resourceFactory{ + lotus: lotus, + configFile: configFile, + } +} + +func (rf *resourceFactory) PreparerJobName() string { + return jobName(rf.lotus.Name, JobPreparer) +} + +func (rf *resourceFactory) MonitorJobName() string { + return jobName(rf.lotus.Name, JobMonitor) +} + +func (rf *resourceFactory) CleanerJobName() string { + return jobName(rf.lotus.Name, JobCleaner) +} + +func (rf *resourceFactory) WorkerName() string { + return workerName(rf.lotus.Name) +} + +func (rf *resourceFactory) PrometheusName() string { + return prometheusName(rf.lotus.Name) +} + +func (rf *resourceFactory) NewPreparerJob() (*batchv1.Job, error) { + return newJob( + rf.lotus, + rf.lotus.Spec.Preparer.Containers, + rf.lotus.Spec.Preparer.Volumes, + JobPreparer, + ), nil +} + +func (rf *resourceFactory) NewCleanerJob() (*batchv1.Job, error) { + return newJob( + rf.lotus, + rf.lotus.Spec.Cleaner.Containers, + rf.lotus.Spec.Cleaner.Volumes, + JobCleaner, + ), nil +} + +func (rf *resourceFactory) NewMonitorJob() (*batchv1.Job, error) { + cfg, err := config.FromFile(rf.configFile) + if err != nil { + return nil, err + } + return newMonitorJob(rf.lotus, cfg), nil +} + +func (rf *resourceFactory) NewMonitorConfigMap() (*corev1.ConfigMap, error) { + cfg, err := buildLotusConfig(rf.configFile, rf.lotus) + if err != nil { + return nil, err + } + data, err := cfg.MarshalToYaml() + if err != nil { + return nil, err + } + return newMonitorConfigMap(rf.lotus, data), nil +} + +func (rf *resourceFactory) NewWorkerDeployment() (*appsv1.Deployment, error) { + return newWorkerDeployment(rf.lotus), nil +} + +func (rf *resourceFactory) NewWorkerService() (*corev1.Service, error) { + return newWorkerService(rf.lotus), nil +} + +func (rf *resourceFactory) NewPrometheusPod(serviceAccountName, release string) (*corev1.Pod, error) { + cfg, err := buildLotusConfig(rf.configFile, rf.lotus) + if err != nil { + return nil, err + } + return newPrometheusPod(rf.lotus, serviceAccountName, release, cfg) +} + +func (rf *resourceFactory) NewPrometheusService() (*corev1.Service, error) { + return newPrometheusService(rf.lotus), nil +} + +func (rf *resourceFactory) NewPrometheusConfigMap() (*corev1.ConfigMap, error) { + cfg, err := config.FromFile(rf.configFile) + if err != nil { + return nil, err + } + target := workerName(rf.lotus.Name) + return newPrometheusConfigMap(rf.lotus, target, cfg.LotusChecks()) +} + +func buildLotusConfig(configFile string, lotus *lotusv1beta1.Lotus) (*config.Config, error) { + cfg, err := config.FromFile(configFile) + if err != nil { + return nil, err + } + cfg.DataSources = append(cfg.DataSources, clientPrometheusDataSource(lotus)) + cfg.AddChecks(lotus.Spec.Checks...) + for i := range cfg.Checks { + if cfg.Checks[i].DataSource == "" { + cfg.Checks[i].DataSource = localPrometheusDataSourceName + } + } + return cfg, nil +} + +func ownerReferences(lotus *lotusv1beta1.Lotus) []metav1.OwnerReference { + return []metav1.OwnerReference{ + *metav1.NewControllerRef(lotus, model.ControllerKind), + } +} diff --git a/pkg/app/lotus/resource/job.go b/pkg/app/lotus/resource/job.go new file mode 100644 index 0000000..97aa5e1 --- /dev/null +++ b/pkg/app/lotus/resource/job.go @@ -0,0 +1,148 @@ +package resource + +import ( + "fmt" + "time" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + "github.com/nghialv/lotus/pkg/app/lotus/config" +) + +type JobType string + +const ( + JobPreparer JobType = "preparer" + JobMonitor = "monitor" + JobCleaner = "cleaner" +) + +func newMonitorJob(lotus *lotusv1beta1.Lotus, cfg *config.Config) *batchv1.Job { + args := []string{ + "monitor", + fmt.Sprintf("--test-id=%s", lotus.Name), + fmt.Sprintf("--run-time=%s", lotus.Spec.Worker.RunTime), + "--config-file=/etc/monitor/config/config.yaml", + fmt.Sprintf("--collect-summary-datasource=%s", localPrometheusDataSourceName), + } + if s := lotus.Spec.CheckIntervalSeconds; s != nil { + d := time.Duration(*s) * time.Second + args = append(args, fmt.Sprintf("--check-interval=%s", d.String())) + } + if s := lotus.Spec.CheckInitialDelaySeconds; s != nil { + d := time.Duration(*s) * time.Second + args = append(args, fmt.Sprintf("--check-initial-delay=%s", d.String())) + } + container := corev1.Container{ + Name: "monitor", + Image: lotusImage, + Args: args, + Env: []corev1.EnvVar{}, + VolumeMounts: []corev1.VolumeMount{ + corev1.VolumeMount{ + Name: "config", + ReadOnly: true, + MountPath: "/etc/monitor/config", + }, + }, + } + volumes := []corev1.Volume{ + corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: jobName(lotus.Name, JobMonitor), + }, + }, + }, + }, + } + for _, receiver := range cfg.Receivers { + gcsReceiver, ok := receiver.Type.(*config.Receiver_Gcs) + if !ok { + continue + } + if gcsReceiver.Gcs.Credentials != nil { + volumeName := fmt.Sprintf("gcs-credentials-%s", receiver.Name) + volumes = append(volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: gcsReceiver.Gcs.Credentials.Secret, + }, + }, + }) + path := receiver.CredentialsMountPath() + container.VolumeMounts = append(container.VolumeMounts, + corev1.VolumeMount{ + Name: volumeName, + MountPath: path, + }, + ) + container.Env = append(container.Env, corev1.EnvVar{ + Name: "GOOGLE_APPLICATION_CREDENTIALS", + Value: fmt.Sprintf("%s%s", path, gcsReceiver.Gcs.Credentials.File), + }) + } + } + return newJob( + lotus, + []corev1.Container{container}, + volumes, + JobMonitor, + ) +} + +func newMonitorConfigMap(lotus *lotusv1beta1.Lotus, config []byte) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName(lotus.Name, JobMonitor), + Namespace: lotus.Namespace, + OwnerReferences: ownerReferences(lotus), + }, + BinaryData: map[string][]byte{ + "config.yaml": config, + }, + } +} + +func newJob(lotus *lotusv1beta1.Lotus, containers []corev1.Container, volumes []corev1.Volume, jt JobType) *batchv1.Job { + var backoffLimit int32 + labels := jobLabels(lotus.Name, jt) + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName(lotus.Name, jt), + Namespace: lotus.Namespace, + OwnerReferences: ownerReferences(lotus), + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: containers, + Volumes: volumes, + }, + }, + }, + } +} + +func jobName(lotusName string, jt JobType) string { + return fmt.Sprintf("%s-%s", lotusName, string(jt)) +} + +func jobLabels(lotusName string, jt JobType) map[string]string { + return map[string]string{ + "app": "lotus-job", + "lotus": lotusName, + "job-type": string(jt), + } +} diff --git a/pkg/app/lotus/resource/prometheus.go b/pkg/app/lotus/resource/prometheus.go new file mode 100644 index 0000000..92c0538 --- /dev/null +++ b/pkg/app/lotus/resource/prometheus.go @@ -0,0 +1,213 @@ +package resource + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" + "github.com/nghialv/lotus/pkg/app/lotus/config" +) + +const ( + prometheusConfigDirectory = "/etc/prometheus" + prometheusConfigFile = "prometheus-config.yaml" + prometheusRuleFile = "prometheus-rule.yaml" + prometheusPort = 9090 + localPrometheusDataSourceName = "_LocalPrometheus" + prometheusBlockDuration = "1m" +) + +func newPrometheusPod(lotus *lotusv1beta1.Lotus, serviceAccount, release string, cfg *config.Config) (*corev1.Pod, error) { + volumes := []corev1.Volume{ + corev1.Volume{ + Name: "db", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: prometheusName(lotus.Name), + }, + }, + }, + }, + } + prometheusContainer := corev1.Container{ + Name: "prometheus", + Image: prometheusImage, + Args: []string{ + fmt.Sprintf("--config.file=%s/%s", prometheusConfigDirectory, prometheusConfigFile), + "--storage.tsdb.path=/var/prometheus", + fmt.Sprintf("--storage.tsdb.min-block-duration=%s", prometheusBlockDuration), + fmt.Sprintf("--storage.tsdb.max-block-duration=%s", prometheusBlockDuration), + "--storage.tsdb.retention=6h", + "--web.enable-lifecycle", + }, + Ports: []corev1.ContainerPort{ + corev1.ContainerPort{ + Name: "prom-http", + ContainerPort: prometheusPort, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + corev1.VolumeMount{ + Name: "config", + MountPath: prometheusConfigDirectory, + }, + corev1.VolumeMount{ + Name: "db", + MountPath: "/var/prometheus", + }, + }, + } + thanosContainer := corev1.Container{ + Name: "thanos-sidecar", + Image: thanosImage, + Args: []string{ + "sidecar", + "--tsdb.path=/var/prometheus", + fmt.Sprintf("--prometheus.url=http://127.0.0.1:%d", prometheusPort), + "--cluster.disable", + }, + Env: []corev1.EnvVar{}, + Ports: thanosPorts(), + VolumeMounts: []corev1.VolumeMount{ + corev1.VolumeMount{ + Name: "config", + MountPath: prometheusConfigDirectory, + }, + corev1.VolumeMount{ + Name: "db", + MountPath: "/var/prometheus", + }, + }, + } + if cfg.TimeSeriesStorage != nil { + setTimeSeriesStoreConfig(&thanosContainer, &volumes, release) + if gcs, ok := cfg.TimeSeriesStorage.Type.(*config.TimeSeriesStorage_Gcs); ok { + if gcs.Gcs.Credentials != nil { + setGCSCredentials(&thanosContainer, &volumes, gcs.Gcs.Credentials) + } + } + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: prometheusName(lotus.Name), + Namespace: lotus.Namespace, + OwnerReferences: ownerReferences(lotus), + Labels: prometheusPodLabels(lotus.Name, release), + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + prometheusContainer, + thanosContainer, + }, + Volumes: volumes, + }, + } + if serviceAccount != "" { + pod.Spec.ServiceAccountName = serviceAccount + } + return pod, nil +} + +func newPrometheusService(lotus *lotusv1beta1.Lotus) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: prometheusName(lotus.Name), + Namespace: lotus.Namespace, + OwnerReferences: ownerReferences(lotus), + }, + Spec: corev1.ServiceSpec{ + Selector: prometheusServiceLabels(lotus.Name), + Ports: []corev1.ServicePort{ + corev1.ServicePort{ + Name: "metrics", + TargetPort: intstr.FromInt(prometheusPort), + Port: int32(prometheusPort), + }, + }, + }, + } +} + +func newPrometheusConfigMap(lotus *lotusv1beta1.Lotus, target string, globalChecks []lotusv1beta1.LotusCheck) (*corev1.ConfigMap, error) { + config, err := renderTemplate( + &prometheusConfigParams{ + Name: prometheusName(lotus.Name), + Namespace: lotus.Namespace, + ServiceName: target, + RuleFiles: []string{ + fmt.Sprintf("%s/%s", prometheusConfigDirectory, prometheusRuleFile), + }, + }, + prometheusConfigTemplate, + ) + if err != nil { + return nil, err + } + globalChecks = append(globalChecks, lotus.Spec.Checks...) + rule, err := renderTemplate( + &prometheusRuleParams{ + Alerts: globalChecks, + }, + prometheusRuleTemplate, + ) + if err != nil { + return nil, err + } + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: prometheusName(lotus.Name), + Namespace: lotus.Namespace, + OwnerReferences: ownerReferences(lotus), + }, + BinaryData: map[string][]byte{ + prometheusConfigFile: config, + prometheusRuleFile: rule, + }, + }, nil +} + +func prometheusName(lotusName string) string { + return fmt.Sprintf("%s-prometheus", lotusName) +} + +func prometheusServiceLabels(lotusName string) map[string]string { + return map[string]string{ + "app": "lotus-prometheus", + "lotus": lotusName, + } +} + +func prometheusPodLabels(lotusName, release string) map[string]string { + return map[string]string{ + "app": "lotus-prometheus", + "lotus": lotusName, + thanosPeerLabel: release, + } +} + +func clientPrometheusDataSource(lotus *lotusv1beta1.Lotus) *config.DataSource { + address := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", + prometheusName(lotus.Name), + lotus.Namespace, + prometheusPort, + ) + return &config.DataSource{ + Name: localPrometheusDataSourceName, + Type: &config.DataSource_Prometheus{ + Prometheus: &config.PrometheusConfigs{ + Address: address, + }, + }, + } +} diff --git a/pkg/app/lotus/resource/secret.go b/pkg/app/lotus/resource/secret.go new file mode 100644 index 0000000..5161837 --- /dev/null +++ b/pkg/app/lotus/resource/secret.go @@ -0,0 +1,59 @@ +package resource + +import ( + "fmt" + + "github.com/ghodss/yaml" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/nghialv/lotus/pkg/app/lotus/config" +) + +const ( + timeSeriesStoreConfigFile = "store-config.yaml" +) + +type ThanosStore struct { + Type string `json:"type"` + Config interface{} `json:"config"` +} + +type ThanosGCSConfig struct { + Bucket string `json:"bucket"` +} + +func generateThanosStoreConfig(cfg *config.TimeSeriesStorage) ([]byte, error) { + switch store := cfg.Type.(type) { + case *config.TimeSeriesStorage_Gcs: + return yaml.Marshal(&ThanosStore{ + Type: "GCS", + Config: &ThanosGCSConfig{ + Bucket: store.Gcs.Bucket, + }, + }) + default: + return nil, fmt.Errorf("unsupported store: %v", cfg.Type) + } +} + +func newTimeSeriesStoreConfigSecret(namespace, release string, cfg *config.TimeSeriesStorage, owners []metav1.OwnerReference) (*corev1.Secret, error) { + data, err := generateThanosStoreConfig(cfg) + if err != nil { + return nil, err + } + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: timeSeriesStoreConfigSecretName(release), + Namespace: namespace, + OwnerReferences: owners, + }, + Data: map[string][]byte{ + timeSeriesStoreConfigFile: data, + }, + }, nil +} + +func timeSeriesStoreConfigSecretName(release string) string { + return fmt.Sprintf("%s-time-series-store-config", release) +} diff --git a/pkg/app/lotus/resource/static_factory.go b/pkg/app/lotus/resource/static_factory.go new file mode 100644 index 0000000..2e0be17 --- /dev/null +++ b/pkg/app/lotus/resource/static_factory.go @@ -0,0 +1,82 @@ +package resource + +import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/nghialv/lotus/pkg/app/lotus/config" +) + +type StaticResourceFactory interface { + ThanosStoreName() string + ThanosQueryName() string + ThanosPeerName() string + TimeSeriesStoreConfigSecretName() string + + NewThanosStoreStatefulSet() (*appsv1.StatefulSet, error) + NewThanosQueryDeployment() (*appsv1.Deployment, error) + NewThanosQueryService() (*corev1.Service, error) + NewThanosPeerService() (*corev1.Service, error) + NewTimeSeriesStoreConfigSecret() (*corev1.Secret, error) +} + +type staticResourceFactory struct { + namespace string + release string + configFile string + ownerReferences []metav1.OwnerReference +} + +func NewStaticResourceFactory(namespace, release, configFile string, owners []metav1.OwnerReference) StaticResourceFactory { + return &staticResourceFactory{ + namespace: namespace, + release: release, + configFile: configFile, + ownerReferences: owners, + } +} + +func (f *staticResourceFactory) ThanosStoreName() string { + return thanosStoreName(f.release) +} + +func (f *staticResourceFactory) ThanosQueryName() string { + return thanosQueryName(f.release) +} + +func (f *staticResourceFactory) ThanosPeerName() string { + return thanosPeerName(f.release) +} + +func (f *staticResourceFactory) TimeSeriesStoreConfigSecretName() string { + return timeSeriesStoreConfigSecretName(f.release) +} + +func (f *staticResourceFactory) NewThanosStoreStatefulSet() (*appsv1.StatefulSet, error) { + cfg, err := config.FromFile(f.configFile) + if err != nil { + return nil, err + } + return newThanosStoreStatefulSet(f.namespace, f.release, cfg.TimeSeriesStorage, f.ownerReferences) +} + +func (f *staticResourceFactory) NewThanosQueryDeployment() (*appsv1.Deployment, error) { + return newThanosQueryDeployment(f.namespace, f.release, f.ownerReferences), nil +} + +func (f *staticResourceFactory) NewThanosQueryService() (*corev1.Service, error) { + return newThanosQueryService(f.namespace, f.release, f.ownerReferences), nil +} + +func (f *staticResourceFactory) NewThanosPeerService() (*corev1.Service, error) { + return newThanosPeerService(f.namespace, f.release, f.ownerReferences), nil +} + +func (f *staticResourceFactory) NewTimeSeriesStoreConfigSecret() (*corev1.Secret, error) { + cfg, err := config.FromFile(f.configFile) + if err != nil { + return nil, err + } + return newTimeSeriesStoreConfigSecret(f.namespace, f.release, cfg.TimeSeriesStorage, f.ownerReferences) +} diff --git a/pkg/app/lotus/resource/templates.go b/pkg/app/lotus/resource/templates.go new file mode 100644 index 0000000..5645c2f --- /dev/null +++ b/pkg/app/lotus/resource/templates.go @@ -0,0 +1,123 @@ +package resource + +import ( + "bytes" + "text/template" + + "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" +) + +func renderTemplate(params interface{}, tpl string) ([]byte, error) { + template, err := template.New("template").Parse(tpl) + if err != nil { + return nil, err + } + var buffer bytes.Buffer + err = template.Execute(&buffer, params) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +type prometheusConfigParams struct { + Name string + Namespace string + ServiceName string + RuleFiles []string +} + +const prometheusConfigTemplate = ` +global: + scrape_interval: 5s + scrape_timeout: 5s + evaluation_interval: 5s + external_labels: + monitor: prometheus + replica: {{ .Name }} +scrape_configs: +- job_name: lotus-runer + metrics_path: /metrics + scheme: http + kubernetes_sd_configs: + - api_server: null + role: endpoints + namespaces: + names: + - {{ .Namespace }} + relabel_configs: + - source_labels: [__meta_kubernetes_service_name] + separator: ; + regex: {{ .ServiceName }} + replacement: $1 + action: keep + - source_labels: [__meta_kubernetes_namespace] + separator: ; + regex: (.*) + target_label: namespace + replacement: $1 + action: replace + - source_labels: [__meta_kubernetes_pod_name] + separator: ; + regex: (.*) + target_label: pod + replacement: $1 + action: replace + - source_labels: [__meta_kubernetes_service_name] + separator: ; + regex: (.*) + target_label: service + replacement: $1 + action: replace + - source_labels: [__meta_kubernetes_service_name] + separator: ; + regex: (.*) + target_label: job + replacement: ${1} + action: replace +{{- if gt (len .RuleFiles) 0 }} +rule_files: +{{- range .RuleFiles }} + - {{ . }} +{{- end }} +{{- end }} +` + +type prometheusRuleParams struct { + Alerts []v1beta1.LotusCheck +} + +const prometheusRuleTemplate = ` +groups: +- name: lotus + rules: +{{- range .Alerts }} + - alert: {{ .Name }} + expr: {{ .Expr }} + for: {{ .For }} +{{- end }} + - record: lotus_virtual_user_failure_percentage + expr: 100 * sum by (job) (lotus_virtual_user_count{virtual_user_status="failed"}) / sum by (job) (lotus_virtual_user_count{virtual_user_status="started"}) + - record: lotus_grpc_client_completed_rpcs_per_second:method + expr: sum by (job, grpc_client_method) (rate(lotus_grpc_client_completed_rpcs[1m])) + - record: lotus_grpc_client_completed_rpcs_per_second:status + expr: sum by (job, grpc_client_status) (rate(lotus_grpc_client_completed_rpcs[1m])) + - record: lotus_grpc_client_completed_rpcs_failure_percentage:method + expr: 100 * sum by (job, grpc_client_method) (rate(lotus_grpc_client_completed_rpcs{grpc_client_status!~"OK|NOT_FOUND|ALREADY_EXISTS"}[1m])) / sum by (job, grpc_client_method) (rate(lotus_grpc_client_completed_rpcs[1m])) + - record: lotus_grpc_client_roundtrip_latency:method + expr: sum by (job, grpc_client_method) (rate(lotus_grpc_client_roundtrip_latency_sum[1m])) / sum by (job, grpc_client_method) (rate(lotus_grpc_client_roundtrip_latency_count[1m])) + - record: lotus_grpc_client_sent_bytes_per_rpc:method + expr: sum by (job, grpc_client_method) (rate(lotus_grpc_client_sent_bytes_per_rpc_sum[1m])) / sum by (job, grpc_client_method) (rate(lotus_grpc_client_sent_bytes_per_rpc_count[1m])) + - record: lotus_grpc_client_received_bytes_per_rpc:method + expr: sum by (job, grpc_client_method) (rate(lotus_grpc_client_received_bytes_per_rpc_sum[1m])) / sum by (job, grpc_client_method) (rate(lotus_grpc_client_received_bytes_per_rpc_count[1m])) + - record: lotus_http_client_completed_requests_per_second:host:route:method + expr: sum by (job, http_client_host, http_client_route, http_client_method) (rate(lotus_http_client_completed_count[1m])) + - record: lotus_http_client_completed_requests_5xx_percentage:host:route:method + expr: 100 * sum by (job, http_client_host, http_client_route, http_client_method) (rate(lotus_http_client_completed_count{http_client_status=~"5.."}[1m])) / sum by (job, http_client_host, http_client_route, http_client_method) (rate(lotus_http_client_completed_count[1m])) + - record: lotus_http_client_roundtrip_latency:host:route:method + expr: sum by (job, http_client_host, http_client_route, http_client_method) (rate(lotus_http_client_roundtrip_latency_sum[1m])) / sum by (job, http_client_host, http_client_route, http_client_method) (rate(lotus_http_client_roundtrip_latency_count[1m])) + - record: lotus_http_client_sent_bytes:host:route:method + expr: sum by (job, http_client_host, http_client_route, http_client_method) (rate(lotus_http_client_sent_bytes_sum[1m])) / sum by (job, http_client_host, http_client_route, http_client_method) (rate(lotus_http_client_sent_bytes_count[1m])) + - record: lotus_http_client_received_bytes:host:route:method + expr: sum by (job, http_client_host, http_client_route, http_client_method) (rate(lotus_http_client_received_bytes_sum[1m])) / sum by (job, http_client_host, http_client_route, http_client_method) (rate(lotus_http_client_received_bytes_count[1m])) +` diff --git a/pkg/app/lotus/resource/templates_test.go b/pkg/app/lotus/resource/templates_test.go new file mode 100644 index 0000000..13f87dd --- /dev/null +++ b/pkg/app/lotus/resource/templates_test.go @@ -0,0 +1,22 @@ +package resource + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenderTemplate(t *testing.T) { + params := &prometheusConfigParams{ + Namespace: "default", + ServiceName: "foo", + RuleFiles: []string{ + "rule-file-1.yaml", + "rule-file-2.yaml", + }, + } + cfg, err := renderTemplate(params, prometheusConfigTemplate) + require.NoError(t, err) + fmt.Println(string(cfg)) +} diff --git a/pkg/app/lotus/resource/thanos.go b/pkg/app/lotus/resource/thanos.go new file mode 100644 index 0000000..36d93ea --- /dev/null +++ b/pkg/app/lotus/resource/thanos.go @@ -0,0 +1,242 @@ +package resource + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/nghialv/lotus/pkg/app/lotus/config" +) + +const ( + thanosPeerLabel = "lotus-thanos-peer" +) + +func newThanosStoreStatefulSet(namespace, release string, cfg *config.TimeSeriesStorage, owners []metav1.OwnerReference) (*appsv1.StatefulSet, error) { + volumes := []corev1.Volume{ + corev1.Volume{ + Name: "data", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + } + container := corev1.Container{ + Name: "thanos-store", + Image: thanosImage, + Args: []string{ + "store", + "--data-dir=/var/thanos/store", + "--cluster.disable", + }, + Env: []corev1.EnvVar{}, + Ports: thanosPorts(), + VolumeMounts: []corev1.VolumeMount{ + corev1.VolumeMount{ + Name: "data", + MountPath: "/var/thanos/store", + }, + }, + } + if cfg != nil { + setTimeSeriesStoreConfig(&container, &volumes, release) + if gcs, ok := cfg.Type.(*config.TimeSeriesStorage_Gcs); ok { + if gcs.Gcs.Credentials != nil { + setGCSCredentials(&container, &volumes, gcs.Gcs.Credentials) + } + } + } + + labels := thanosStoreLabels(release) + replicas := int32(1) + + statefulset := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: thanosStoreName(release), + Namespace: namespace, + OwnerReferences: owners, + Labels: labels, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{container}, + Volumes: volumes, + }, + }, + }, + } + return statefulset, nil +} + +func newThanosPeerService(namespace, release string, owners []metav1.OwnerReference) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: thanosPeerName(release), + Namespace: namespace, + OwnerReferences: owners, + }, + Spec: corev1.ServiceSpec{ + Selector: thanosPeerLabels(release), + ClusterIP: "None", + Ports: []corev1.ServicePort{ + corev1.ServicePort{ + Name: "grpc", + TargetPort: intstr.FromString("grpc"), + Port: int32(10901), + }, + }, + }, + } +} + +func newThanosQueryDeployment(namespace, release string, owners []metav1.OwnerReference) *appsv1.Deployment { + replicas := int32(1) + labels := thanosQueryLabels(release) + containers := []corev1.Container{ + corev1.Container{ + Name: "thanos-query", + Image: thanosImage, + Args: []string{ + "query", + "--query.replica-label=replica", + "--cluster.disable", + fmt.Sprintf("--store=dns+%s.%s.svc.cluster.local:10901", thanosPeerName(release), namespace), + }, + Ports: thanosPorts(), + }, + } + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: thanosQueryName(release), + Namespace: namespace, + OwnerReferences: owners, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + Containers: containers, + }, + }, + }, + } +} + +func newThanosQueryService(namespace, release string, owners []metav1.OwnerReference) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: thanosQueryName(release), + Namespace: namespace, + OwnerReferences: owners, + }, + Spec: corev1.ServiceSpec{ + Selector: thanosQueryLabels(release), + Ports: []corev1.ServicePort{ + corev1.ServicePort{ + Name: "http", + TargetPort: intstr.FromString("http"), + Port: int32(9090), + }, + }, + }, + } +} + +func thanosStoreName(release string) string { + return fmt.Sprintf("%s-thanos-store", release) +} + +func thanosPeerName(release string) string { + return fmt.Sprintf("%s-thanos-peers", release) +} + +func thanosQueryName(release string) string { + return fmt.Sprintf("%s-thanos-query", release) +} + +func thanosStoreLabels(release string) map[string]string { + return map[string]string{ + "app": thanosStoreName(release), + thanosPeerLabel: release, + } +} + +func thanosPeerLabels(release string) map[string]string { + return map[string]string{ + thanosPeerLabel: release, + } +} + +func thanosQueryLabels(release string) map[string]string { + return map[string]string{ + "app": thanosQueryName(release), + } +} + +func thanosPorts() []corev1.ContainerPort { + return []corev1.ContainerPort{ + corev1.ContainerPort{ + Name: "http", + ContainerPort: 10902, + }, + corev1.ContainerPort{ + Name: "grpc", + ContainerPort: 10901, + }, + } +} + +func setGCSCredentials(container *corev1.Container, volumes *[]corev1.Volume, credentials *config.SecretFileSelector) { + container.Env = append(container.Env, corev1.EnvVar{ + Name: "GOOGLE_APPLICATION_CREDENTIALS", + Value: fmt.Sprintf("/creds/gcs/%s", credentials.File), + }) + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: "gcs-credentials", + MountPath: "/creds/gcs/", + }) + *volumes = append(*volumes, corev1.Volume{ + Name: "gcs-credentials", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: credentials.Secret, + }, + }, + }) +} + +func setTimeSeriesStoreConfig(container *corev1.Container, volumes *[]corev1.Volume, release string) { + container.Args = append(container.Args, + fmt.Sprintf("--objstore.config-file=/creds/objstore/%s", timeSeriesStoreConfigFile), + ) + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: "time-series-store-config", + MountPath: "/creds/objstore/", + }) + *volumes = append(*volumes, corev1.Volume{ + Name: "time-series-store-config", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: timeSeriesStoreConfigSecretName(release), + }, + }, + }) +} diff --git a/pkg/app/lotus/resource/worker.go b/pkg/app/lotus/resource/worker.go new file mode 100644 index 0000000..145d99b --- /dev/null +++ b/pkg/app/lotus/resource/worker.go @@ -0,0 +1,72 @@ +package resource + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + lotusv1beta1 "github.com/nghialv/lotus/pkg/app/lotus/apis/lotus/v1beta1" +) + +func newWorkerDeployment(lotus *lotusv1beta1.Lotus) *appsv1.Deployment { + labels := workerLabels(lotus.Name) + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: workerName(lotus.Name), + Namespace: lotus.Namespace, + OwnerReferences: ownerReferences(lotus), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: lotus.Spec.Worker.Replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + Containers: lotus.Spec.Worker.Containers, + }, + }, + }, + } +} + +func newWorkerService(lotus *lotusv1beta1.Lotus) *corev1.Service { + labels := workerLabels(lotus.Name) + metricsPort := *lotus.Spec.Worker.MetricsPort + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: workerName(lotus.Name), + Namespace: lotus.Namespace, + OwnerReferences: ownerReferences(lotus), + }, + Spec: corev1.ServiceSpec{ + Selector: labels, + Ports: []corev1.ServicePort{ + corev1.ServicePort{ + Name: "metrics", + TargetPort: intstr.FromInt(int(metricsPort)), + Port: metricsPort, + }, + }, + }, + } + +} + +func workerName(lotusName string) string { + return fmt.Sprintf("%s-worker", lotusName) +} + +func workerLabels(lotusName string) map[string]string { + return map[string]string{ + "app": "lotus-worker", + "lotus": lotusName, + } +} diff --git a/pkg/cli/BUILD.bazel b/pkg/cli/BUILD.bazel new file mode 100644 index 0000000..b2ea292 --- /dev/null +++ b/pkg/cli/BUILD.bazel @@ -0,0 +1,17 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "app.go", + "cmd.go", + ], + importpath = "github.com/nghialv/lotus/pkg/cli", + visibility = ["//visibility:public"], + deps = [ + "//pkg/log:go_default_library", + "//pkg/version:go_default_library", + "@com_github_spf13_cobra//:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/cli/app.go b/pkg/cli/app.go new file mode 100644 index 0000000..713ecff --- /dev/null +++ b/pkg/cli/app.go @@ -0,0 +1,52 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nghialv/lotus/pkg/log" + "github.com/nghialv/lotus/pkg/version" +) + +type App struct { + rootCmd *cobra.Command + logLevel string + logEncoding string +} + +func NewApp(name, desc string) *App { + a := &App{ + rootCmd: &cobra.Command{ + Use: name, + Short: desc, + }, + logLevel: log.DefaultLevel, + logEncoding: log.DefaultEncoding, + } + versionCmd := &cobra.Command{ + Use: "version", + Short: "Print the information of current binary", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(version.Get()) + }, + } + a.rootCmd.AddCommand(versionCmd) + a.setGlobalFlags() + return a +} + +func (a *App) AddCommands(cmds ...*cobra.Command) { + for _, cmd := range cmds { + a.rootCmd.AddCommand(cmd) + } +} + +func (a *App) Run() error { + return a.rootCmd.Execute() +} + +func (a *App) setGlobalFlags() { + a.rootCmd.PersistentFlags().StringVar(&a.logLevel, "log-level", a.logLevel, "The minimum enabled logging level") + a.rootCmd.PersistentFlags().StringVar(&a.logEncoding, "log-encoding", a.logEncoding, "The encoding type for logger [json|console]") +} diff --git a/pkg/cli/cmd.go b/pkg/cli/cmd.go new file mode 100644 index 0000000..955e211 --- /dev/null +++ b/pkg/cli/cmd.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/nghialv/lotus/pkg/log" + "github.com/nghialv/lotus/pkg/version" +) + +func WithContext(runner func(context.Context, *zap.Logger) error) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + return runWithContext(cmd, runner) + } +} + +func runWithContext(cmd *cobra.Command, runner func(context.Context, *zap.Logger) error) error { + logger, err := newLogger(cmd) + if err != nil { + return err + } + defer logger.Sync() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(ch) + + go func() { + select { + case s := <-ch: + logger.Info("stopping due to signal", zap.Stringer("signal", s)) + cancel() + case <-ctx.Done(): + } + }() + + logger.Info(fmt.Sprintf("running %s", cmd.CommandPath())) + return runner(ctx, logger) +} + +func newLogger(cmd *cobra.Command) (*zap.Logger, error) { + configs := log.DefaultConfigs + configs.ServiceContext = &log.ServiceContext{ + Service: strings.Replace(cmd.CommandPath(), " ", ".", -1), + Version: version.Get().Version, + } + if f := cmd.Flag("log-level"); f != nil { + configs.Level = f.Value.String() + } + if f := cmd.Flag("log-encoding"); f != nil { + configs.Encoding = f.Value.String() + } + return log.NewLogger(configs) +} diff --git a/pkg/log/BUILD.bazel b/pkg/log/BUILD.bazel new file mode 100644 index 0000000..0798b58 --- /dev/null +++ b/pkg/log/BUILD.bazel @@ -0,0 +1,20 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_test( + name = "go_default_test", + size = "small", #keep + srcs = ["log_test.go"], + embed = [":go_default_library"], + deps = ["@com_github_stretchr_testify//assert:go_default_library"], +) + +go_library( + name = "go_default_library", + srcs = ["log.go"], + importpath = "github.com/nghialv/lotus/pkg/log", + visibility = ["//visibility:public"], + deps = [ + "@org_uber_go_zap//:go_default_library", + "@org_uber_go_zap//zapcore:go_default_library", + ], +) diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 0000000..ff79450 --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,108 @@ +package log + +import ( + "errors" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +const ( + DefaultLevel = "info" + DefaultEncoding = "json" +) + +var ( + DefaultConfigs = Configs{ + Level: DefaultLevel, + Encoding: DefaultEncoding, + } +) + +type Configs struct { + Level string + Encoding string + ServiceContext *ServiceContext +} + +func NewLogger(c Configs) (*zap.Logger, error) { + level := new(zapcore.Level) + if err := level.Set(c.Level); err != nil { + return nil, err + } + var options []zap.Option + if c.ServiceContext != nil { + options = []zap.Option{ + zap.Fields(zap.Object("serviceContext", c.ServiceContext)), + } + } + logger, err := newConfig(*level, c.Encoding).Build(options...) + if err != nil { + return nil, err + } + return logger.Named(c.ServiceContext.Service), nil +} + +func newConfig(level zapcore.Level, encoding string) zap.Config { + return zap.Config{ + Level: zap.NewAtomicLevelAt(level), + Development: false, + Sampling: &zap.SamplingConfig{ + Initial: 100, + Thereafter: 100, + }, + Encoding: encoding, + EncoderConfig: newEncoderConfig(), + OutputPaths: []string{"stderr"}, + ErrorOutputPaths: []string{"stderr"}, + } +} + +func newEncoderConfig() zapcore.EncoderConfig { + return zapcore.EncoderConfig{ + TimeKey: "eventTime", + LevelKey: "severity", + NameKey: "logger", + CallerKey: "caller", + MessageKey: "message", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: encodeLevel, + EncodeTime: zapcore.EpochTimeEncoder, + EncodeDuration: zapcore.SecondsDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } +} + +func encodeLevel(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) { + switch l { + case zapcore.DebugLevel: + enc.AppendString("DEBUG") + case zapcore.InfoLevel: + enc.AppendString("INFO") + case zapcore.WarnLevel: + enc.AppendString("WARNING") + case zapcore.ErrorLevel: + enc.AppendString("ERROR") + case zapcore.DPanicLevel: + enc.AppendString("CRITICAL") + case zapcore.PanicLevel: + enc.AppendString("ALERT") + case zapcore.FatalLevel: + enc.AppendString("EMERGENCY") + } +} + +type ServiceContext struct { + Service string + Version string +} + +func (sc ServiceContext) MarshalLogObject(enc zapcore.ObjectEncoder) error { + if sc.Service == "" { + return errors.New("service name is mandatory") + } + enc.AddString("service", sc.Service) + enc.AddString("version", sc.Version) + return nil +} diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go new file mode 100644 index 0000000..fe857e5 --- /dev/null +++ b/pkg/log/log_test.go @@ -0,0 +1,59 @@ +package log + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewLoggerOK(t *testing.T) { + validLevels := []string{ + "debug", + "info", + "warn", + "error", + "dpanic", + "panic", + "fatal", + } + validEncodings := []string{ + "json", + "console", + } + for _, level := range validLevels { + for _, encoding := range validEncodings { + cfg := Configs{ + Level: level, + Encoding: encoding, + ServiceContext: &ServiceContext{ + Service: "test-service", + Version: "1.0.0", + }, + } + logger, err := NewLogger(cfg) + des := fmt.Sprintf("level: %s, encoding: %s", level, encoding) + assert.Nil(t, err, des) + assert.NotNil(t, logger, des) + } + } +} + +func TestNewLoggerFailed(t *testing.T) { + configs := []Configs{ + Configs{ + Level: "foo", + Encoding: "json", + }, + Configs{ + Level: "info", + Encoding: "foo", + }, + } + for _, cfg := range configs { + logger, err := NewLogger(cfg) + des := fmt.Sprintf("level: %s, encoding: %s", cfg.Level, cfg.Encoding) + assert.NotNil(t, err, des) + assert.Nil(t, logger, des) + } +} diff --git a/pkg/metrics/BUILD.bazel b/pkg/metrics/BUILD.bazel new file mode 100644 index 0000000..478f767 --- /dev/null +++ b/pkg/metrics/BUILD.bazel @@ -0,0 +1,25 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "logger.go", + "metrics.go", + ], + importpath = "github.com/nghialv/lotus/pkg/metrics", + visibility = ["//visibility:public"], + deps = [ + "//pkg/metrics/grpcmetrics:go_default_library", + "//pkg/metrics/httpmetrics:go_default_library", + "//pkg/virtualuser:go_default_library", + "@io_opencensus_go//exporter/prometheus:go_default_library", + "@io_opencensus_go//stats/view:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["metrics_test.go"], + embed = [":go_default_library"], +) diff --git a/pkg/metrics/grpcmetrics/BUILD.bazel b/pkg/metrics/grpcmetrics/BUILD.bazel new file mode 100644 index 0000000..266b76e --- /dev/null +++ b/pkg/metrics/grpcmetrics/BUILD.bazel @@ -0,0 +1,22 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "common.go", + "grpc.go", + "grpc_stats.go", + ], + importpath = "github.com/nghialv/lotus/pkg/metrics/grpcmetrics", + visibility = ["//visibility:public"], + deps = [ + "@io_opencensus_go//stats:go_default_library", + "@io_opencensus_go//stats/view:go_default_library", + "@io_opencensus_go//tag:go_default_library", + "@org_golang_google_grpc//codes:go_default_library", + "@org_golang_google_grpc//grpclog:go_default_library", + "@org_golang_google_grpc//stats:go_default_library", + "@org_golang_google_grpc//status:go_default_library", + "@org_golang_x_net//context:go_default_library", + ], +) diff --git a/pkg/metrics/grpcmetrics/common.go b/pkg/metrics/grpcmetrics/common.go new file mode 100644 index 0000000..b3da913 --- /dev/null +++ b/pkg/metrics/grpcmetrics/common.go @@ -0,0 +1,163 @@ +// Copyright 2018, OpenCensus Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grpcmetrics + +import ( + "context" + "strconv" + "strings" + "sync/atomic" + "time" + + ocstats "go.opencensus.io/stats" + "go.opencensus.io/tag" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/stats" + "google.golang.org/grpc/status" +) + +type rpcData struct { + sentCount, sentBytes, recvCount, recvBytes int64 + startTime time.Time + method string +} + +type grpcInstrumentationKey string + +var ( + rpcDataKey = grpcInstrumentationKey("lotus-rpcData") +) + +func methodName(fullname string) string { + return strings.TrimLeft(fullname, "/") +} + +func statsHandleRPC(ctx context.Context, s stats.RPCStats) { + switch st := s.(type) { + case *stats.Begin, *stats.OutHeader, *stats.InHeader, *stats.InTrailer, *stats.OutTrailer: + // do nothing for client + case *stats.OutPayload: + handleRPCOutPayload(ctx, st) + case *stats.InPayload: + handleRPCInPayload(ctx, st) + case *stats.End: + handleRPCEnd(ctx, st) + default: + grpclog.Infof("unexpected stats: %T", st) + } +} + +func handleRPCOutPayload(ctx context.Context, s *stats.OutPayload) { + d, ok := ctx.Value(rpcDataKey).(*rpcData) + if !ok { + if grpclog.V(2) { + grpclog.Infoln("Failed to retrieve *rpcData from context.") + } + return + } + atomic.AddInt64(&d.sentBytes, int64(s.Length)) + atomic.AddInt64(&d.sentCount, 1) +} + +func handleRPCInPayload(ctx context.Context, s *stats.InPayload) { + d, ok := ctx.Value(rpcDataKey).(*rpcData) + if !ok { + if grpclog.V(2) { + grpclog.Infoln("Failed to retrieve *rpcData from context.") + } + return + } + atomic.AddInt64(&d.recvBytes, int64(s.Length)) + atomic.AddInt64(&d.recvCount, 1) +} + +func handleRPCEnd(ctx context.Context, s *stats.End) { + d, ok := ctx.Value(rpcDataKey).(*rpcData) + if !ok { + if grpclog.V(2) { + grpclog.Infoln("Failed to retrieve *rpcData from context.") + } + return + } + elapsedTime := time.Since(d.startTime) + + var st string + if s.Error != nil { + s, ok := status.FromError(s.Error) + if ok { + st = statusCodeToString(s) + } + } else { + st = "OK" + } + + latencyMillis := float64(elapsedTime) / float64(time.Millisecond) + if !s.Client { + return + } + ocstats.RecordWithTags(ctx, + []tag.Mutator{ + tag.Upsert(KeyClientMethod, methodName(d.method)), + tag.Upsert(KeyClientStatus, st), + }, + ClientSentBytesPerRPC.M(atomic.LoadInt64(&d.sentBytes)), + ClientSentMessagesPerRPC.M(atomic.LoadInt64(&d.sentCount)), + ClientReceivedMessagesPerRPC.M(atomic.LoadInt64(&d.recvCount)), + ClientReceivedBytesPerRPC.M(atomic.LoadInt64(&d.recvBytes)), + ClientRoundtripLatency.M(latencyMillis)) +} + +func statusCodeToString(s *status.Status) string { + // see https://github.com/grpc/grpc/blob/master/doc/statuscodes.md + switch c := s.Code(); c { + case codes.OK: + return "OK" + case codes.Canceled: + return "CANCELLED" + case codes.Unknown: + return "UNKNOWN" + case codes.InvalidArgument: + return "INVALID_ARGUMENT" + case codes.DeadlineExceeded: + return "DEADLINE_EXCEEDED" + case codes.NotFound: + return "NOT_FOUND" + case codes.AlreadyExists: + return "ALREADY_EXISTS" + case codes.PermissionDenied: + return "PERMISSION_DENIED" + case codes.ResourceExhausted: + return "RESOURCE_EXHAUSTED" + case codes.FailedPrecondition: + return "FAILED_PRECONDITION" + case codes.Aborted: + return "ABORTED" + case codes.OutOfRange: + return "OUT_OF_RANGE" + case codes.Unimplemented: + return "UNIMPLEMENTED" + case codes.Internal: + return "INTERNAL" + case codes.Unavailable: + return "UNAVAILABLE" + case codes.DataLoss: + return "DATA_LOSS" + case codes.Unauthenticated: + return "UNAUTHENTICATED" + default: + return "CODE_" + strconv.FormatInt(int64(c), 10) + } +} diff --git a/pkg/metrics/grpcmetrics/grpc.go b/pkg/metrics/grpcmetrics/grpc.go new file mode 100644 index 0000000..bed0e1b --- /dev/null +++ b/pkg/metrics/grpcmetrics/grpc.go @@ -0,0 +1,65 @@ +// Copyright 2018, OpenCensus Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grpcmetrics + +import ( + "time" + + "go.opencensus.io/tag" + "golang.org/x/net/context" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/stats" +) + +type ClientHandler struct { +} + +func (c *ClientHandler) HandleConn(ctx context.Context, cs stats.ConnStats) { + // no-op +} + +func (c *ClientHandler) TagConn(ctx context.Context, cti *stats.ConnTagInfo) context.Context { + // no-op + return ctx +} + +func (c *ClientHandler) HandleRPC(ctx context.Context, rs stats.RPCStats) { + statsHandleRPC(ctx, rs) +} + +func (c *ClientHandler) TagRPC(ctx context.Context, rti *stats.RPCTagInfo) context.Context { + ctx = c.statsTagRPC(ctx, rti) + return ctx +} + +func (h *ClientHandler) statsTagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { + startTime := time.Now() + if info == nil { + if grpclog.V(2) { + grpclog.Infoln("Failed to retrieve *rpcData from context.") + } + return ctx + } + d := &rpcData{ + startTime: startTime, + method: info.FullMethodName, + } + ts := tag.FromContext(ctx) + if ts != nil { + encoded := tag.Encode(ts) + ctx = stats.SetTags(ctx, encoded) + } + return context.WithValue(ctx, rpcDataKey, d) +} diff --git a/pkg/metrics/grpcmetrics/grpc_stats.go b/pkg/metrics/grpcmetrics/grpc_stats.go new file mode 100644 index 0000000..6a75fab --- /dev/null +++ b/pkg/metrics/grpcmetrics/grpc_stats.go @@ -0,0 +1,123 @@ +// Copyright 2018, OpenCensus Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grpcmetrics + +import ( + "go.opencensus.io/stats" + "go.opencensus.io/stats/view" + "go.opencensus.io/tag" +) + +var ( + ClientSentMessagesPerRPC = stats.Int64( + "grpc/client/sent_messages_per_rpc", + "Number of messages sent in the RPC (always 1 for non-streaming RPCs).", + stats.UnitDimensionless, + ) + + ClientSentBytesPerRPC = stats.Int64( + "grpc/client/sent_bytes_per_rpc", + "Total bytes sent across all request messages per RPC.", + stats.UnitBytes, + ) + + ClientReceivedMessagesPerRPC = stats.Int64( + "grpc/client/received_messages_per_rpc", + "Number of response messages received per RPC (always 1 for non-streaming RPCs).", + stats.UnitDimensionless, + ) + + ClientReceivedBytesPerRPC = stats.Int64( + "grpc/client/received_bytes_per_rpc", + "Total bytes received across all response messages per RPC.", + stats.UnitBytes, + ) + + ClientRoundtripLatency = stats.Float64( + "grpc/client/roundtrip_latency", + "Time between first byte of request sent to last byte of response received, or terminal error.", + stats.UnitMilliseconds, + ) +) + +var ( + KeyClientMethod, _ = tag.NewKey("grpc_client_method") + KeyClientStatus, _ = tag.NewKey("grpc_client_status") +) + +var ( + ClientSentBytesPerRPCView = &view.View{ + Measure: ClientSentBytesPerRPC, + Name: "grpc/client/sent_bytes_per_rpc", + Description: "Distribution of bytes sent per RPC, by method.", + TagKeys: []tag.Key{KeyClientMethod}, + Aggregation: DefaultBytesDistribution, + } + + ClientReceivedBytesPerRPCView = &view.View{ + Measure: ClientReceivedBytesPerRPC, + Name: "grpc/client/received_bytes_per_rpc", + Description: "Distribution of bytes received per RPC, by method.", + TagKeys: []tag.Key{KeyClientMethod}, + Aggregation: DefaultBytesDistribution, + } + + ClientRoundtripLatencyView = &view.View{ + Measure: ClientRoundtripLatency, + Name: "grpc/client/roundtrip_latency", + Description: "Distribution of round-trip latency, by method.", + TagKeys: []tag.Key{KeyClientMethod}, + Aggregation: DefaultMillisecondsDistribution, + } + + ClientCompletedRPCsView = &view.View{ + Measure: ClientRoundtripLatency, + Name: "grpc/client/completed_rpcs", + Description: "Count of RPCs by method and status.", + TagKeys: []tag.Key{KeyClientMethod, KeyClientStatus}, + Aggregation: view.Count(), + } + + ClientSentMessagesPerRPCView = &view.View{ + Measure: ClientSentMessagesPerRPC, + Name: "grpc/client/sent_messages_per_rpc", + Description: "Distribution of sent messages count per RPC, by method.", + TagKeys: []tag.Key{KeyClientMethod}, + Aggregation: DefaultMessageCountDistribution, + } + + ClientReceivedMessagesPerRPCView = &view.View{ + Measure: ClientReceivedMessagesPerRPC, + Name: "grpc/client/received_messages_per_rpc", + Description: "Distribution of received messages count per RPC, by method.", + TagKeys: []tag.Key{KeyClientMethod}, + Aggregation: DefaultMessageCountDistribution, + } +) + +var DefaultClientViews = []*view.View{ + ClientSentBytesPerRPCView, + ClientReceivedBytesPerRPCView, + ClientRoundtripLatencyView, + ClientCompletedRPCsView, + ClientSentMessagesPerRPCView, + ClientReceivedMessagesPerRPCView, +} + +var ( + DefaultBytesDistribution = view.Distribution(0, 1024, 2048, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, 4294967296) + DefaultMillisecondsDistribution = view.Distribution(0, 0.01, 0.05, 0.1, 0.3, 0.6, 0.8, 1, 2, 3, 4, 5, 6, 8, 10, 13, 16, 20, 25, 30, 40, 50, 65, 80, 100, 130, 160, 200, 250, 300, 400, 500, 650, 800, 1000, 2000, 5000, 10000, 20000, 50000, 100000) + DefaultMessageCountDistribution = view.Distribution(0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536) +) diff --git a/pkg/metrics/httpmetrics/BUILD.bazel b/pkg/metrics/httpmetrics/BUILD.bazel new file mode 100644 index 0000000..d73eea9 --- /dev/null +++ b/pkg/metrics/httpmetrics/BUILD.bazel @@ -0,0 +1,16 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "http.go", + "http_stats.go", + ], + importpath = "github.com/nghialv/lotus/pkg/metrics/httpmetrics", + visibility = ["//visibility:public"], + deps = [ + "@io_opencensus_go//stats:go_default_library", + "@io_opencensus_go//stats/view:go_default_library", + "@io_opencensus_go//tag:go_default_library", + ], +) diff --git a/pkg/metrics/httpmetrics/http.go b/pkg/metrics/httpmetrics/http.go new file mode 100644 index 0000000..7a1d21a --- /dev/null +++ b/pkg/metrics/httpmetrics/http.go @@ -0,0 +1,138 @@ +// Copyright 2018, OpenCensus Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpmetrics + +import ( + "context" + "io" + "net/http" + "strconv" + "sync" + "time" + + "go.opencensus.io/stats" + "go.opencensus.io/tag" +) + +type Transport struct { + Base http.RoundTripper + UsePathAsRoute bool +} + +func ContextWithRoute(ctx context.Context, route string) (context.Context, error) { + return tag.New(ctx, tag.Upsert(KeyClientRoute, route)) +} + +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + mutators := []tag.Mutator{ + tag.Upsert(KeyClientHost, req.URL.Host), + tag.Upsert(KeyClientMethod, req.Method), + } + if t.UsePathAsRoute { + mutators = append(mutators, tag.Upsert(KeyClientRoute, req.URL.Path)) + } else { + mutators = append(mutators, tag.Insert(KeyClientRoute, "no_route")) + } + ctx, _ := tag.New(req.Context(), mutators...) + req = req.WithContext(ctx) + track := &tracker{ + start: time.Now(), + ctx: ctx, + } + track.reqSize = req.ContentLength + if req.Body == nil && req.ContentLength == -1 { + track.reqSize = 0 + } + + resp, err := t.base().RoundTrip(req) + if err != nil { + track.statusCode = http.StatusInternalServerError + track.end() + } else { + track.statusCode = resp.StatusCode + track.respContentLength = resp.ContentLength + if resp.Body == nil { + track.end() + } else { + track.body = resp.Body + resp.Body = track + } + } + return resp, err +} + +func (t *Transport) CancelRequest(req *http.Request) { + type canceler interface { + CancelRequest(*http.Request) + } + if cr, ok := t.base().(canceler); ok { + cr.CancelRequest(req) + } +} + +func (t *Transport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +type tracker struct { + ctx context.Context + respSize int64 + respContentLength int64 + reqSize int64 + start time.Time + body io.ReadCloser + statusCode int + endOnce sync.Once +} + +var _ io.ReadCloser = (*tracker)(nil) + +func (t *tracker) end() { + t.endOnce.Do(func() { + latencyMs := float64(time.Since(t.start)) / float64(time.Millisecond) + respSize := t.respSize + if t.respSize == 0 && t.respContentLength > 0 { + respSize = t.respContentLength + } + m := []stats.Measurement{ + ClientSentBytes.M(t.reqSize), + ClientReceivedBytes.M(respSize), + ClientRoundtripLatency.M(latencyMs), + } + stats.RecordWithTags(t.ctx, []tag.Mutator{ + tag.Upsert(KeyClientStatus, strconv.Itoa(t.statusCode)), + }, m...) + }) +} + +func (t *tracker) Read(b []byte) (int, error) { + n, err := t.body.Read(b) + t.respSize += int64(n) + switch err { + case nil: + return n, nil + case io.EOF: + t.end() + } + return n, err +} + +func (t *tracker) Close() error { + t.end() + return t.body.Close() +} diff --git a/pkg/metrics/httpmetrics/http_stats.go b/pkg/metrics/httpmetrics/http_stats.go new file mode 100644 index 0000000..4fc367b --- /dev/null +++ b/pkg/metrics/httpmetrics/http_stats.go @@ -0,0 +1,101 @@ +// Copyright 2018, OpenCensus Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpmetrics + +import ( + "go.opencensus.io/stats" + "go.opencensus.io/stats/view" + "go.opencensus.io/tag" +) + +var ( + ClientSentBytes = stats.Int64( + "http/client/sent_bytes", + "Total bytes sent in request body (not including headers)", + stats.UnitBytes, + ) + ClientReceivedBytes = stats.Int64( + "http/client/received_bytes", + "Total bytes received in response bodies (not including headers but including error responses with bodies)", + stats.UnitBytes, + ) + ClientRoundtripLatency = stats.Float64( + "http/client/roundtrip_latency", + "Time between first byte of request headers sent to last byte of response received, or terminal error", + stats.UnitMilliseconds, + ) +) + +// KeyClientRoute is a low cardinality string representing the logical +// handler of the request. This is usually the pattern of the request. +var ( + KeyClientHost, _ = tag.NewKey("http_client_host") + KeyClientRoute, _ = tag.NewKey("http_client_route") + KeyClientMethod, _ = tag.NewKey("http_client_method") + KeyClientStatus, _ = tag.NewKey("http_client_status") + + HTTPClientTagKeys = []tag.Key{ + KeyClientHost, + KeyClientMethod, + KeyClientRoute, + KeyClientStatus, + } +) + +var ( + ClientSentBytesDistribution = &view.View{ + Name: "http/client/sent_bytes", + Measure: ClientSentBytes, + Aggregation: DefaultSizeDistribution, + Description: "Total bytes sent in request body (not including headers)", + TagKeys: HTTPClientTagKeys, + } + + ClientReceivedBytesDistribution = &view.View{ + Name: "http/client/received_bytes", + Measure: ClientReceivedBytes, + Aggregation: DefaultSizeDistribution, + Description: "Total bytes received in response bodies (not including headers but including error responses with bodies)", + TagKeys: HTTPClientTagKeys, + } + + ClientRoundtripLatencyDistribution = &view.View{ + Name: "http/client/roundtrip_latency", + Measure: ClientRoundtripLatency, + Aggregation: DefaultLatencyDistribution, + Description: "End-to-end latency", + TagKeys: HTTPClientTagKeys, + } + + ClientCompletedCount = &view.View{ + Name: "http/client/completed_count", + Measure: ClientRoundtripLatency, + Aggregation: view.Count(), + Description: "Count of completed requests", + TagKeys: HTTPClientTagKeys, + } +) + +var DefaultClientViews = []*view.View{ + ClientCompletedCount, + ClientSentBytesDistribution, + ClientReceivedBytesDistribution, + ClientRoundtripLatencyDistribution, +} + +var ( + DefaultSizeDistribution = view.Distribution(0, 1024, 2048, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, 4294967296) + DefaultLatencyDistribution = view.Distribution(0, 1, 2, 3, 4, 5, 6, 8, 10, 13, 16, 20, 25, 30, 40, 50, 65, 80, 100, 130, 160, 200, 250, 300, 400, 500, 650, 800, 1000, 2000, 5000, 10000, 20000, 50000, 100000) +) diff --git a/pkg/metrics/logger.go b/pkg/metrics/logger.go new file mode 100644 index 0000000..b5083d5 --- /dev/null +++ b/pkg/metrics/logger.go @@ -0,0 +1,18 @@ +package metrics + +import "log" + +type Logger interface { + Infof(format string, args ...interface{}) + Errorf(format string, args ...interface{}) +} + +type logger struct{} + +func (l logger) Infof(format string, args ...interface{}) { + log.Printf(format, args...) +} + +func (l logger) Errorf(format string, args ...interface{}) { + log.Printf(format, args...) +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go new file mode 100644 index 0000000..5e8c0ab --- /dev/null +++ b/pkg/metrics/metrics.go @@ -0,0 +1,156 @@ +package metrics + +import ( + "context" + "fmt" + "net/http" + "time" + + "go.opencensus.io/exporter/prometheus" + "go.opencensus.io/stats/view" + + "github.com/nghialv/lotus/pkg/metrics/grpcmetrics" + "github.com/nghialv/lotus/pkg/metrics/httpmetrics" + "github.com/nghialv/lotus/pkg/virtualuser" +) + +type options struct { + namespace string + path string + reportingPeriod time.Duration + gracefulPeriod time.Duration + grpcViews []*view.View + httpViews []*view.View + virtualUserViews []*view.View + customViews []*view.View + logger Logger +} + +var defaultOptions = options{ + namespace: "lotus", + path: "/metrics", + reportingPeriod: time.Second, + gracefulPeriod: 5 * time.Second, + grpcViews: grpcmetrics.DefaultClientViews, + httpViews: httpmetrics.DefaultClientViews, + virtualUserViews: []*view.View{ + virtualuser.UserCountView, + }, + logger: logger{}, +} + +type Option func(*options) + +func WithNamespace(namespace string) Option { + return func(opts *options) { + opts.namespace = namespace + } +} + +func WithPath(path string) Option { + return func(opts *options) { + opts.path = path + } +} + +func WithReportingPeriod(period time.Duration) Option { + return func(opts *options) { + opts.reportingPeriod = period + } +} + +func WithGracefulPeriod(period time.Duration) Option { + return func(opts *options) { + opts.gracefulPeriod = period + } +} + +func WithCustomViews(views ...*view.View) Option { + return func(opts *options) { + opts.customViews = views + } +} + +func WithGrpcViews(views ...*view.View) Option { + return func(opts *options) { + opts.grpcViews = views + } +} + +func WithHttpViews(views ...*view.View) Option { + return func(opts *options) { + opts.httpViews = views + } +} + +func WithLogger(logger Logger) Option { + return func(opts *options) { + opts.logger = logger + } +} + +func (o options) Views() []*view.View { + length := len(o.grpcViews) + len(o.httpViews) + len(o.virtualUserViews) + len(o.customViews) + views := make([]*view.View, 0, length) + views = append(views, o.grpcViews...) + views = append(views, o.httpViews...) + views = append(views, o.virtualUserViews...) + views = append(views, o.customViews...) + return views +} + +type server struct { + server *http.Server + opts options +} + +func NewServer(port int, opt ...Option) (*server, error) { + opts := defaultOptions + for _, o := range opt { + o(&opts) + } + view.SetReportingPeriod(opts.reportingPeriod) + err := view.Register(opts.Views()...) + if err != nil { + opts.logger.Errorf("failed to register views: %v", err) + return nil, err + } + pe, err := prometheus.NewExporter(prometheus.Options{ + Namespace: opts.namespace, + }) + if err != nil { + opts.logger.Errorf("failed to create prometheus exporter: %v", err) + return nil, err + } + view.RegisterExporter(pe) + + mux := http.NewServeMux() + mux.Handle(opts.path, pe) + s := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + } + return &server{ + server: s, + opts: opts, + }, nil +} + +func (s *server) Run() error { + err := s.server.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + s.opts.logger.Errorf("failed to run metrics server: %v", err) + return err + } + return nil +} + +func (s *server) Stop() error { + ctx, cancel := context.WithTimeout(context.Background(), s.opts.gracefulPeriod) + defer cancel() + err := s.server.Shutdown(ctx) + if err != nil { + s.opts.logger.Errorf("failed to shutdown metrics server: %v", err) + } + return err +} diff --git a/pkg/metrics/metrics_test.go b/pkg/metrics/metrics_test.go new file mode 100644 index 0000000..1abe097 --- /dev/null +++ b/pkg/metrics/metrics_test.go @@ -0,0 +1 @@ +package metrics diff --git a/pkg/version/BUILD.bazel b/pkg/version/BUILD.bazel new file mode 100644 index 0000000..dff254b --- /dev/null +++ b/pkg/version/BUILD.bazel @@ -0,0 +1,10 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") +load("//pkg/version:def.bzl", "version_x_defs") + +go_library( + name = "go_default_library", + srcs = ["version.go"], + importpath = "github.com/nghialv/lotus/pkg/version", + visibility = ["//visibility:public"], + x_defs = version_x_defs(), +) diff --git a/pkg/version/def.bzl b/pkg/version/def.bzl new file mode 100644 index 0000000..85dfa72 --- /dev/null +++ b/pkg/version/def.bzl @@ -0,0 +1,35 @@ +# Copyright 2017 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# See https://github.com/nghialv/lotus/tree/master/NOTICE.md + +def version_x_defs(): + stamp_pkgs = [ + "github.com/nghialv/lotus/pkg/version", + ] + + # This should match the list of vars set in hack/print-workspace-status.sh. + stamp_vars = [ + "gitCommit", + "gitCommitFull", + "buildDate", + "version", + ] + + # Generate the cross-product. + x_defs = {} + for pkg in stamp_pkgs: + for var in stamp_vars: + x_defs["%s.%s" % (pkg, var)] = "{%s}" % var + return x_defs diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..9b4ecd6 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,36 @@ +package version + +import "fmt" + +var ( + gitCommit = "unspecified" + gitCommitFull = "unspecified" + buildDate = "unspecified" + version = "unspecified" +) + +type Info struct { + GitCommit string + GitCommitFull string + BuildDate string + Version string +} + +func Get() Info { + return Info{ + GitCommit: gitCommit, + GitCommitFull: gitCommitFull, + BuildDate: buildDate, + Version: version, + } +} + +func (i Info) String() string { + return fmt.Sprintf( + "Version: %s, GitCommit: %s, GitCommitFull: %s, BuildDate: %s", + i.Version, + i.GitCommit, + i.GitCommitFull, + i.BuildDate, + ) +} diff --git a/pkg/virtualuser/BUILD.bazel b/pkg/virtualuser/BUILD.bazel new file mode 100644 index 0000000..900764b --- /dev/null +++ b/pkg/virtualuser/BUILD.bazel @@ -0,0 +1,20 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["virtualuser.go"], + importpath = "github.com/nghialv/lotus/pkg/virtualuser", + visibility = ["//visibility:public"], + deps = [ + "@io_opencensus_go//stats:go_default_library", + "@io_opencensus_go//stats/view:go_default_library", + "@io_opencensus_go//tag:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["virtualuser_test.go"], + embed = [":go_default_library"], +) diff --git a/pkg/virtualuser/virtualuser.go b/pkg/virtualuser/virtualuser.go new file mode 100644 index 0000000..0114b13 --- /dev/null +++ b/pkg/virtualuser/virtualuser.go @@ -0,0 +1,121 @@ +package virtualuser + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.opencensus.io/stats" + "go.opencensus.io/stats/view" + "go.opencensus.io/tag" +) + +type ( + VirtualUser interface { + Run(ctx context.Context) error + } + + Factory func() (VirtualUser, error) +) + +const ( + StatusStarted = "started" + StatusSucceeded = "succeeded" + StatusFailed = "failed" +) + +var ( + UserCount = stats.Int64("virtual_user/count", "Number of virtual users", stats.UnitDimensionless) + + KeyStatus, _ = tag.NewKey("virtual_user_status") + + UserCountView = &view.View{ + Name: "virtual_user/count", + Measure: UserCount, + TagKeys: []tag.Key{KeyStatus}, + Description: "Count of virtual users by status", + Aggregation: view.Count(), + } +) + +type Group struct { + numUsers int + hatchRate int + factory Factory + doneCh chan struct{} +} + +func NewGroup(numUsers, hatchRate int, factory Factory) *Group { + return &Group{ + numUsers: numUsers, + hatchRate: hatchRate, + factory: factory, + doneCh: make(chan struct{}), + } +} + +func (g *Group) Run(ctx context.Context) error { + startedCtx, err := tag.New(ctx, tag.Insert(KeyStatus, StatusStarted)) + if err != nil { + return err + } + succeededCtx, err := tag.New(ctx, tag.Insert(KeyStatus, StatusSucceeded)) + if err != nil { + return err + } + failedCtx, err := tag.New(ctx, tag.Insert(KeyStatus, StatusFailed)) + if err != nil { + return err + } + var index int = 0 + var wg sync.WaitGroup + for index < g.numUsers { + for i := 0; i < g.hatchRate && index < g.numUsers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + stats.Record(startedCtx, UserCount.M(1)) + var err error + defer func() { + if r := recover(); r != nil { + err = errors.New("virtualuser.group: panic") + } + if err != nil { + stats.Record(succeededCtx, UserCount.M(1)) + } else { + stats.Record(failedCtx, UserCount.M(1)) + } + }() + var vu VirtualUser + vu, err = g.factory() + if err != nil { + return + } + err = vu.Run(ctx) + }() + index++ + } + if index >= g.numUsers { + break + } + select { + case <-ctx.Done(): + break + case <-time.After(time.Second): + } + } + wg.Wait() + close(g.doneCh) + return nil +} + +func (g *Group) Stop(timeout time.Duration) error { + select { + case <-g.doneCh: + return nil + case <-time.After(timeout): + return fmt.Errorf("timed out") + } +} diff --git a/pkg/virtualuser/virtualuser_test.go b/pkg/virtualuser/virtualuser_test.go new file mode 100644 index 0000000..e706c05 --- /dev/null +++ b/pkg/virtualuser/virtualuser_test.go @@ -0,0 +1 @@ +package virtualuser