├── .bingo ├── .gitignore ├── README.md ├── Variables.mk ├── bingo.mod ├── bingo.sum ├── dex.mod ├── embedmd.mod ├── embedmd.sum ├── faillint.mod ├── faillint.sum ├── go.mod ├── goimports.mod ├── goimports.sum ├── gojsontoyaml.mod ├── gojsontoyaml.sum ├── golangci-lint.mod ├── golangci-lint.sum ├── goyacc.mod ├── gubernator.mod ├── jb.mod ├── jsonnet.mod ├── jsonnet.sum ├── jsonnetfmt.mod ├── jsonnetfmt.sum ├── kubeval.mod ├── kubeval.sum ├── misspell.mod ├── misspell.sum ├── oapi-codegen.mod ├── oapi-codegen.sum ├── opa.mod ├── protoc-gen-go.mod ├── protoc-gen-go.sum ├── styx.mod ├── up.mod └── variables.env ├── .circleci └── config.yml ├── .github ├── dependabot.yml └── workflows │ └── publish.yml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── Dockerfile.e2e-test ├── LICENSE ├── Makefile ├── README.md ├── api ├── logs │ └── v1 │ │ ├── http.go │ │ ├── labels_enforcer.go │ │ ├── labels_enforcer_test.go │ │ ├── rules.go │ │ ├── rules_labels_enforcer.go │ │ ├── rules_labels_enforcer_test.go │ │ ├── rules_prometheus_labels_enforcer.go │ │ ├── rules_prometheus_labels_enforcer_test.go │ │ └── testdata │ │ ├── alerts.json │ │ ├── rules-log-test-0.json │ │ ├── rules.json │ │ └── rules.yaml ├── metrics │ ├── legacy │ │ └── http.go │ └── v1 │ │ ├── alertmanager_enforcer.go │ │ ├── http.go │ │ ├── labels_enforcer.go │ │ └── rules.go └── traces │ └── v1 │ ├── api.go │ ├── http.go │ ├── trace_rbac.go │ └── trace_rbac_test.go ├── authentication ├── authentication.go ├── authentication_test.go ├── grpc.go ├── http.go ├── metrics.go ├── mtls.go ├── oidc.go ├── openshift.go ├── openshift │ ├── cookie.go │ ├── discovery.go │ └── options.go └── var.go ├── authorization ├── grpc.go ├── http.go ├── meta.go ├── meta_test.go ├── query.go ├── query_test.go ├── rules.go └── rules_test.go ├── build └── conditional-container-push.sh ├── client ├── client.gen.go ├── models │ ├── models.gen.go │ └── models.yaml ├── parameters │ ├── parameters.gen.go │ └── parameters.yaml ├── responses │ ├── responses.gen.go │ └── responses.yaml └── spec.yaml ├── docs ├── .gitkeep ├── benchmark.md └── loadtests │ ├── cpu.png │ ├── goroutines.png │ ├── mem.png │ ├── query_range_dur_50.png │ ├── query_range_dur_99.png │ ├── query_range_dur_avg.png │ ├── results │ ├── cpu.gnuplot │ ├── goroutines.gnuplot │ ├── mem.gnuplot │ ├── query_range_dur_50.gnuplot │ ├── query_range_dur_99.gnuplot │ ├── query_range_dur_avg.gnuplot │ ├── write_dur_50.gnuplot │ ├── write_dur_99.gnuplot │ └── write_dur_avg.gnuplot │ ├── write_dur_50.png │ ├── write_dur_99.png │ └── write_dur_avg.png ├── examples ├── dex │ └── config-dev.yaml ├── main.jsonnet ├── manifests │ ├── configmap-with-tls.yaml │ ├── configmap.yaml │ ├── deployment-with-tls.yaml │ ├── deployment.yaml │ ├── secret-with-tls.yaml │ ├── secret.yaml │ ├── service-with-tls.yaml │ ├── service.yaml │ ├── serviceAccount-with-tls.yaml │ └── serviceAccount.yaml ├── rbac │ └── simple.yaml └── tenants │ ├── simple.yaml │ └── two.yaml ├── go.mod ├── go.sum ├── httperr └── httperr.go ├── jsonnet └── lib │ └── observatorium-api.libsonnet ├── logger └── logger.go ├── logql └── v2 │ ├── ast.go │ ├── ast_test.go │ ├── expr.y │ ├── expr.y.go │ ├── lexer.go │ ├── parser.go │ └── parser_test.go ├── main.go ├── opa ├── opa.go └── opa_test.go ├── proxy └── proxy.go ├── ratelimit ├── client.go ├── gcra_rate_limit.lua ├── gubernator │ ├── README.md │ ├── gubernator.pb.go │ ├── gubernator.proto │ └── proto │ │ └── google │ │ ├── api │ │ ├── annotations.proto │ │ ├── http.proto │ │ └── httpbody.proto │ │ └── rpc │ │ ├── code.proto │ │ ├── error_details.proto │ │ └── status.proto ├── http.go ├── http_test.go └── redis.go ├── rbac ├── rbac.go └── rbac_test.go ├── rules ├── custom_types.go ├── custom_types_test.go ├── rules.go └── spec.yaml ├── scripts ├── generate_proto.sh └── install_protoc.sh ├── server ├── http_instrumentation.go ├── http_instrumentation_test.go ├── instrumentation.go └── paths.go ├── test ├── config │ ├── hashrings.json │ ├── observatorium.rego │ ├── prometheus.yml │ └── rbac.yaml ├── dex │ ├── static │ │ └── .gitkeep │ ├── templates │ │ ├── approval.html │ │ ├── error.html │ │ ├── login.html │ │ ├── oob.html │ │ └── password.html │ └── themes │ │ └── coreos │ │ └── .gitkeep ├── e2e │ ├── alerts_test.go │ ├── configs.go │ ├── helpers.go │ ├── interactive_test.go │ ├── logs_test.go │ ├── metrics_test.go │ ├── redis_rate_limiter_test.go │ ├── rules_test.go │ ├── services.go │ ├── tenants_test.go │ └── traces_test.go ├── load.sh ├── mock │ └── provider.go └── testtls │ └── generate.go ├── tls ├── ca_watcher.go ├── ca_watcher_test.go ├── config.go ├── options.go └── options_test.go └── tracing └── tracing.go /.bingo/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Ignore everything 3 | * 4 | 5 | # But not these files: 6 | !.gitignore 7 | !*.mod 8 | !*.sum 9 | !README.md 10 | !Variables.mk 11 | !variables.env 12 | 13 | *tmp.mod 14 | -------------------------------------------------------------------------------- /.bingo/README.md: -------------------------------------------------------------------------------- 1 | # Project Development Dependencies. 2 | 3 | This is directory which stores Go modules with pinned buildable package that is used within this repository, managed by https://github.com/bwplotka/bingo. 4 | 5 | * Run `bingo get` to install all tools having each own module file in this directory. 6 | * Run `bingo get ` to install that have own module file in this directory. 7 | * For Makefile: Make sure to put `include .bingo/Variables.mk` in your Makefile, then use $() variable where is the .bingo/.mod. 8 | * For shell: Run `source .bingo/variables.env` to source all environment variable for each tool. 9 | * For go: Import `.bingo/variables.go` to for variable names. 10 | * See https://github.com/bwplotka/bingo or -h on how to add, remove or change binaries dependencies. 11 | 12 | ## Requirements 13 | 14 | * Go 1.14+ 15 | -------------------------------------------------------------------------------- /.bingo/bingo.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/bwplotka/bingo v0.9.0 6 | -------------------------------------------------------------------------------- /.bingo/dex.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | replace ( 6 | go.etcd.io/etcd => go.etcd.io/etcd v0.5.0-alpha.5.0.20200425165423-262c93980547 7 | google.golang.org/grpc => google.golang.org/grpc v1.27.1 8 | ) 9 | 10 | require github.com/dexidp/dex v0.0.0-20200512115545-709d4169d646 // cmd/dex 11 | -------------------------------------------------------------------------------- /.bingo/embedmd.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/campoy/embedmd v1.0.0 6 | -------------------------------------------------------------------------------- /.bingo/embedmd.sum: -------------------------------------------------------------------------------- 1 | github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= 2 | github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | -------------------------------------------------------------------------------- /.bingo/faillint.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.19 4 | 5 | require github.com/fatih/faillint v1.13.0 6 | -------------------------------------------------------------------------------- /.bingo/go.mod: -------------------------------------------------------------------------------- 1 | module _ // Fake go.mod auto-created by 'bingo' for go -moddir compatibility with non-Go projects. Commit this file, together with other .mod files. -------------------------------------------------------------------------------- /.bingo/goimports.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.19 4 | 5 | require golang.org/x/tools v0.4.0 // cmd/goimports 6 | -------------------------------------------------------------------------------- /.bingo/goimports.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= 2 | golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 3 | golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= 4 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 5 | golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4= 6 | golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 7 | -------------------------------------------------------------------------------- /.bingo/gojsontoyaml.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/brancz/gojsontoyaml v0.0.0-20200602132005-3697ded27e8c 6 | -------------------------------------------------------------------------------- /.bingo/gojsontoyaml.sum: -------------------------------------------------------------------------------- 1 | github.com/brancz/gojsontoyaml v0.0.0-20200602132005-3697ded27e8c h1:hb6WqfcKQZlNx/vahy51SaIvKnoXD5609Nm0PC4msEM= 2 | github.com/brancz/gojsontoyaml v0.0.0-20200602132005-3697ded27e8c/go.mod h1:+00lOjYXPgMfxHVPvg9GDtc3BX5Xh5aFpB4gMB8gfMo= 3 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= 4 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 5 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 6 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 7 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 8 | -------------------------------------------------------------------------------- /.bingo/golangci-lint.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.22.1 4 | 5 | toolchain go1.22.8 6 | 7 | require github.com/golangci/golangci-lint v1.61.0 // cmd/golangci-lint 8 | -------------------------------------------------------------------------------- /.bingo/goyacc.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.16 4 | 5 | require golang.org/x/tools v0.1.5 // cmd/goyacc 6 | -------------------------------------------------------------------------------- /.bingo/gubernator.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/mailgun/gubernator v1.0.0-rc.3 // cmd/gubernator 6 | -------------------------------------------------------------------------------- /.bingo/jb.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/jsonnet-bundler/jsonnet-bundler v0.4.0 // cmd/jb 6 | -------------------------------------------------------------------------------- /.bingo/jsonnet.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/google/go-jsonnet v0.16.0 // cmd/jsonnet 6 | -------------------------------------------------------------------------------- /.bingo/jsonnet.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= 4 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 5 | github.com/google/go-jsonnet v0.16.0 h1:Nb4EEOp+rdeGGyB1rQ5eisgSAqrTnhf9ip+X6lzZbY0= 6 | github.com/google/go-jsonnet v0.16.0/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw= 7 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 8 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 9 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 10 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 11 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 12 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 13 | github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= 14 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 17 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 18 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 19 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 20 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= 21 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 25 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 26 | -------------------------------------------------------------------------------- /.bingo/jsonnetfmt.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/google/go-jsonnet v0.16.0 // cmd/jsonnetfmt 6 | -------------------------------------------------------------------------------- /.bingo/jsonnetfmt.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= 4 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 5 | github.com/google/go-jsonnet v0.16.0 h1:Nb4EEOp+rdeGGyB1rQ5eisgSAqrTnhf9ip+X6lzZbY0= 6 | github.com/google/go-jsonnet v0.16.0/go.mod h1:sOcuej3UW1vpPTZOr8L7RQimqai1a57bt5j22LzGZCw= 7 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 8 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 9 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 10 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 11 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 12 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 13 | github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= 14 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 17 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 18 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 19 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 20 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= 21 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 25 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 26 | -------------------------------------------------------------------------------- /.bingo/kubeval.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/instrumenta/kubeval v0.0.0-20201005082916-38668c6c5b23 6 | -------------------------------------------------------------------------------- /.bingo/misspell.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.19 4 | 5 | require github.com/client9/misspell v0.3.4 // cmd/misspell 6 | -------------------------------------------------------------------------------- /.bingo/misspell.sum: -------------------------------------------------------------------------------- 1 | github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= 2 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 3 | -------------------------------------------------------------------------------- /.bingo/oapi-codegen.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.17 4 | 5 | require github.com/deepmap/oapi-codegen v1.9.0 // cmd/oapi-codegen 6 | -------------------------------------------------------------------------------- /.bingo/opa.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/open-policy-agent/opa v0.23.2 6 | -------------------------------------------------------------------------------- /.bingo/protoc-gen-go.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/golang/protobuf v1.4.2 // protoc-gen-go 6 | -------------------------------------------------------------------------------- /.bingo/protoc-gen-go.sum: -------------------------------------------------------------------------------- 1 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 2 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 3 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 4 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 5 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 6 | github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= 7 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 8 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 9 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 10 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 11 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 12 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 13 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 14 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 15 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 16 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 17 | google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= 18 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 19 | -------------------------------------------------------------------------------- /.bingo/styx.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/go-pluto/styx v0.0.0-20200109161911-78a77eb717b4 6 | -------------------------------------------------------------------------------- /.bingo/up.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.15 4 | 5 | require github.com/observatorium/up v0.0.0-20200928171403-120d85735d11 // cmd/up 6 | -------------------------------------------------------------------------------- /.bingo/variables.env: -------------------------------------------------------------------------------- 1 | # Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.9. DO NOT EDIT. 2 | # All tools are designed to be build inside $GOBIN. 3 | # Those variables will work only until 'bingo get' was invoked, or if tools were installed via Makefile's Variables.mk. 4 | GOBIN=${GOBIN:=$(go env GOBIN)} 5 | 6 | if [ -z "$GOBIN" ]; then 7 | GOBIN="$(go env GOPATH)/bin" 8 | fi 9 | 10 | 11 | BINGO="${GOBIN}/bingo-v0.9.0" 12 | 13 | DEX="${GOBIN}/dex-v0.0.0-20200512115545-709d4169d646" 14 | 15 | EMBEDMD="${GOBIN}/embedmd-v1.0.0" 16 | 17 | FAILLINT="${GOBIN}/faillint-v1.13.0" 18 | 19 | GOIMPORTS="${GOBIN}/goimports-v0.4.0" 20 | 21 | GOJSONTOYAML="${GOBIN}/gojsontoyaml-v0.0.0-20200602132005-3697ded27e8c" 22 | 23 | GOLANGCI_LINT="${GOBIN}/golangci-lint-v1.61.0" 24 | 25 | GOYACC="${GOBIN}/goyacc-v0.1.5" 26 | 27 | GUBERNATOR="${GOBIN}/gubernator-v1.0.0-rc.3" 28 | 29 | JB="${GOBIN}/jb-v0.4.0" 30 | 31 | JSONNET="${GOBIN}/jsonnet-v0.16.0" 32 | 33 | JSONNETFMT="${GOBIN}/jsonnetfmt-v0.16.0" 34 | 35 | KUBEVAL="${GOBIN}/kubeval-v0.0.0-20201005082916-38668c6c5b23" 36 | 37 | MISSPELL="${GOBIN}/misspell-v0.3.4" 38 | 39 | OAPI_CODEGEN="${GOBIN}/oapi-codegen-v1.9.0" 40 | 41 | OPA="${GOBIN}/opa-v0.23.2" 42 | 43 | PROTOC_GEN_GO="${GOBIN}/protoc-gen-go-v1.4.2" 44 | 45 | STYX="${GOBIN}/styx-v0.0.0-20200109161911-78a77eb717b4" 46 | 47 | UP="${GOBIN}/up-v0.0.0-20200928171403-120d85735d11" 48 | 49 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: golang:1.23 6 | steps: 7 | - checkout 8 | - run: | 9 | make build 10 | git diff --exit-code 11 | 12 | lint: 13 | docker: 14 | - image: golang:1.23 15 | steps: 16 | - checkout 17 | - run: | 18 | apt-get update && apt-get install xz-utils 19 | make lint --always-make 20 | 21 | test: 22 | machine: 23 | image: default 24 | steps: 25 | - checkout 26 | - run: | 27 | apt-get update && apt-get -y install xz-utils unzip openssl 28 | sudo rm -rf /usr/local/go 29 | wget -qO- https://golang.org/dl/go1.23.5.linux-amd64.tar.gz | sudo tar -C /usr/local -xzf - 30 | export PATH=$PATH:/usr/local/go/bin 31 | go version 32 | make test --always-make 33 | 34 | test-e2e: 35 | machine: 36 | image: default 37 | steps: 38 | - checkout 39 | - run: | 40 | sudo rm -rf /usr/local/go 41 | wget -qO- https://golang.org/dl/go1.23.5.linux-amd64.tar.gz | sudo tar -C /usr/local -xzf - 42 | export PATH=$PATH:/usr/local/go/bin 43 | go version 44 | make test-e2e 45 | 46 | generate: 47 | docker: 48 | - image: golang:1.23 49 | steps: 50 | - checkout 51 | - run: | 52 | make generate validate --always-make 53 | apt-get update && apt-get -y install unzip 54 | make proto 55 | git diff --exit-code 56 | 57 | workflows: 58 | version: 2 59 | test-and-push: 60 | jobs: 61 | - build 62 | - lint 63 | - test 64 | - test-e2e 65 | - generate 66 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | allowed_updates: 8 | - match: 9 | dependency-name: "k8s.io/api" 10 | update-type: "semver-patch" 11 | - match: 12 | dependency-name: "k8s.io/apimachinery" 13 | update-type: "semver-patch" 14 | - match: 15 | dependency-name: "k8s.io/apiserver" 16 | update-type: "semver-patch" 17 | - match: 18 | dependency-name: "k8s.io/client-go" 19 | update-type: "semver-patch" 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | push: 4 | branches: 5 | - 'master' 6 | - 'main' 7 | tags: 8 | - 'v*' 9 | jobs: 10 | image: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Login to image registry 16 | uses: docker/login-action@v2 17 | with: 18 | registry: quay.io 19 | username: ${{ secrets.QUAY_USERNAME }} 20 | password: ${{ secrets.QUAY_PASSWORD }} 21 | 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v2 24 | 25 | - name: Set up Docker Buildx 26 | id: buildx 27 | uses: docker/setup-buildx-action@v2 28 | 29 | - name: Cache for Docker's buildx 30 | uses: actions/cache@v3 31 | with: 32 | path: .buildxcache/ 33 | key: ${{ runner.os }}-buildx-${{ hashFiles('**/*.go', 'Dockerfile', 'go.sum') }} 34 | restore-keys: | 35 | ${{ runner.os }}-buildx- 36 | 37 | - name: Snapshot container buid & push 38 | run: make conditional-container-build-push 39 | 40 | - name: Check semver tag 41 | id: check-semver-tag 42 | # The regex below comes from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string. 43 | run: | 44 | if [[ ${{ github.event.ref }} =~ ^refs/tags/v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ ]]; then 45 | echo ::set-output name=match::true 46 | fi 47 | - name: Release container build & push 48 | if: steps.check-semver-tag.outputs.match == 'true' 49 | run: make container-release-build-push 50 | 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | observatorium 15 | observatorium-api 16 | tmp/ 17 | examples/vendor 18 | vendor 19 | .envrc 20 | .bin 21 | .idea 22 | .vscode 23 | e2e_* 24 | 25 | .buildxcache/ 26 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | # default concurrency is a available CPU number 3 | concurrency: 4 4 | # timeout for analysis, e.g. 30s, 5m, default is 1m 5 | timeout: 5m 6 | tests: true 7 | 8 | # exit code when at least one issue was found, default is 1 9 | issues-exit-code: 1 10 | issues: 11 | exclude-dirs: 12 | - vendor 13 | 14 | # output configuration options 15 | output: 16 | # colored-line-number|line-number|json|tab|checkstyle, default is "colored-line-number" 17 | formats: 18 | - format: colored-line-number 19 | 20 | # print lines of code with issue, default is true 21 | print-issued-lines: true 22 | 23 | # print linter name in the end of issue text, default is true 24 | print-linter-name: true 25 | 26 | linters: 27 | enable: 28 | # Sorted alphabetically. 29 | - copyloopvar 30 | - errcheck 31 | - goconst 32 | - godot 33 | - gofmt 34 | - goimports 35 | - gosimple 36 | - govet 37 | - ineffassign 38 | - misspell 39 | - staticcheck 40 | - typecheck 41 | - unparam 42 | - unused 43 | - promlinter 44 | 45 | linters-settings: 46 | errcheck: 47 | exclude-functions: 48 | - (github.com/go-kit/log.Logger).Log 49 | - fmt.Fprintln 50 | - fmt.Fprint 51 | lll: 52 | line-length: 160 53 | funlen: 54 | lines: 140 55 | statements: 60 56 | misspell: 57 | locale: US 58 | goconst: 59 | min-occurrences: 5 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1.23.5-alpine3.20 as builder 2 | 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | 6 | RUN apk add --update --no-cache ca-certificates tzdata git make bash && update-ca-certificates 7 | 8 | ADD . /opt 9 | WORKDIR /opt 10 | 11 | RUN git update-index --refresh; make build OS=${TARGETOS} ARCH=${TARGETARCH} 12 | 13 | FROM alpine:3.20 as runner 14 | 15 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 16 | COPY --from=builder /opt/observatorium-api /bin/observatorium-api 17 | 18 | ARG BUILD_DATE 19 | ARG VERSION 20 | ARG VCS_REF 21 | ARG DOCKERFILE_PATH 22 | 23 | USER 10000:10000 24 | 25 | LABEL vendor="Observatorium" \ 26 | name="observatorium/api" \ 27 | description="Observatorium API" \ 28 | io.k8s.display-name="observatorium/api" \ 29 | io.k8s.description="Observatorium API" \ 30 | maintainer="Observatorium " \ 31 | version="$VERSION" \ 32 | org.label-schema.build-date=$BUILD_DATE \ 33 | org.label-schema.description="Observatorium API" \ 34 | org.label-schema.docker.cmd="docker run --rm observatorium/api" \ 35 | org.label-schema.docker.dockerfile=$DOCKERFILE_PATH \ 36 | org.label-schema.name="observatorium/api" \ 37 | org.label-schema.schema-version="1.0" \ 38 | org.label-schema.vcs-branch=$VCS_BRANCH \ 39 | org.label-schema.vcs-ref=$VCS_REF \ 40 | org.label-schema.vcs-url="https://github.com/observatorium/api" \ 41 | org.label-schema.vendor="observatorium/api" \ 42 | org.label-schema.version=$VERSION 43 | 44 | 45 | ENTRYPOINT ["/bin/observatorium-api"] 46 | -------------------------------------------------------------------------------- /Dockerfile.e2e-test: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.5-alpine3.20 as builder 2 | 3 | RUN apk add --update --no-cache ca-certificates tzdata git make bash && update-ca-certificates 4 | 5 | ADD . /opt 6 | WORKDIR /opt 7 | 8 | RUN git update-index --refresh; make build 9 | 10 | FROM alpine:3.20 as runner 11 | 12 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 13 | COPY --from=builder /opt/observatorium-api /bin/observatorium-api 14 | 15 | ARG BUILD_DATE 16 | ARG VERSION 17 | ARG VCS_REF 18 | ARG DOCKERFILE_PATH 19 | 20 | USER 10000:10000 21 | 22 | LABEL vendor="Observatorium" \ 23 | name="observatorium/api" \ 24 | description="Observatorium API" \ 25 | io.k8s.display-name="observatorium/api" \ 26 | io.k8s.description="Observatorium API" \ 27 | maintainer="Observatorium " \ 28 | version="$VERSION" \ 29 | org.label-schema.build-date=$BUILD_DATE \ 30 | org.label-schema.description="Observatorium API" \ 31 | org.label-schema.docker.cmd="docker run --rm observatorium/api" \ 32 | org.label-schema.docker.dockerfile=$DOCKERFILE_PATH \ 33 | org.label-schema.name="observatorium/api" \ 34 | org.label-schema.schema-version="1.0" \ 35 | org.label-schema.vcs-branch=$VCS_BRANCH \ 36 | org.label-schema.vcs-ref=$VCS_REF \ 37 | org.label-schema.vcs-url="https://github.com/observatorium/api" \ 38 | org.label-schema.vendor="observatorium/api" \ 39 | org.label-schema.version=$VERSION 40 | 41 | 42 | ENTRYPOINT ["/bin/observatorium-api"] 43 | -------------------------------------------------------------------------------- /api/logs/v1/rules.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/ghodss/yaml" 10 | "github.com/go-chi/chi" 11 | "github.com/observatorium/api/authentication" 12 | "github.com/observatorium/api/httperr" 13 | "github.com/observatorium/api/rules" 14 | ) 15 | 16 | // WithEnforceTenantAsRuleNamespace returns a middleware that ensures that the 17 | // namespace given on loki namespaced rule routes is the same as the tenant name. 18 | func WithEnforceTenantAsRuleNamespace() func(http.Handler) http.Handler { 19 | return func(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | tenant, ok := authentication.GetTenant(r.Context()) 22 | if !ok { 23 | httperr.PrometheusAPIError(w, "error finding tenant ID", http.StatusUnauthorized) 24 | return 25 | } 26 | 27 | rctx := chi.RouteContext(r.Context()) 28 | if rctx == nil { 29 | httperr.PrometheusAPIError(w, "error finding route context", http.StatusInternalServerError) 30 | return 31 | } 32 | 33 | // Exclude ruler discovery calls from Grafana: 34 | // See: https://github.com/grafana/grafana/blob/842ce144292bdf6b51ba2e13961c0986969005e4/public/app/features/alerting/unified/api/ruler.ts#L93-L100 35 | group := chi.URLParam(r, "groupName") 36 | namespace := chi.URLParam(r, "namespace") 37 | if namespace == "test" && group == "test" { 38 | httperr.PrometheusAPIError(w, "page not found", http.StatusNotFound) 39 | return 40 | } 41 | 42 | if namespace != "" && tenant != namespace { 43 | httperr.PrometheusAPIError(w, "error tenant not matching namespace", http.StatusBadRequest) 44 | return 45 | } 46 | 47 | next.ServeHTTP(w, r) 48 | }) 49 | } 50 | } 51 | 52 | // WithEnforceRuleLabels returns a middleware that ensures every rule includes 53 | // the tenant label. 54 | func WithEnforceRuleLabels(tenantLabel string) func(http.Handler) http.Handler { 55 | return func(next http.Handler) http.Handler { 56 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | id, ok := authentication.GetTenantID(r.Context()) 58 | if !ok { 59 | httperr.PrometheusAPIError(w, "error finding tenant ID", http.StatusUnauthorized) 60 | return 61 | } 62 | 63 | defer r.Body.Close() 64 | 65 | group, err := unmarshalRuleGroup(r.Body) 66 | if err != nil { 67 | httperr.PrometheusAPIError(w, "error unmarshalling rule group", http.StatusInternalServerError) 68 | return 69 | } 70 | 71 | err = enforceLabelsInRules(&group, tenantLabel, id) 72 | if err != nil { 73 | httperr.PrometheusAPIError(w, "error enforing labels into rules", http.StatusInternalServerError) 74 | return 75 | } 76 | 77 | body, err := yaml.Marshal(group) 78 | if err != nil { 79 | httperr.PrometheusAPIError(w, "error marshaling rules YAML", http.StatusInternalServerError) 80 | return 81 | } 82 | 83 | nr := r.Clone(r.Context()) 84 | nr.Body = io.NopCloser(bytes.NewReader(body)) 85 | nr.ContentLength = int64(len(body)) 86 | 87 | next.ServeHTTP(w, nr) 88 | }) 89 | } 90 | } 91 | 92 | func unmarshalRuleGroup(r io.Reader) (rules.RuleGroup, error) { 93 | body, err := io.ReadAll(r) 94 | if err != nil { 95 | return rules.RuleGroup{}, err 96 | } 97 | 98 | var rg rules.RuleGroup 99 | if err := yaml.Unmarshal(body, &rg); err != nil { 100 | return rules.RuleGroup{}, err 101 | } 102 | 103 | return rg, nil 104 | } 105 | 106 | func enforceLabelsInRules(rg *rules.RuleGroup, tenantLabel, tenantID string) error { 107 | for i := range rg.Rules { 108 | switch r := rg.Rules[i].(type) { 109 | case rules.RecordingRule: 110 | if r.Labels.AdditionalProperties == nil { 111 | r.Labels.AdditionalProperties = make(map[string]string) 112 | } 113 | 114 | r.Labels.AdditionalProperties[tenantLabel] = tenantID 115 | rg.Rules[i] = r 116 | case rules.AlertingRule: 117 | if r.Labels.AdditionalProperties == nil { 118 | r.Labels.AdditionalProperties = make(map[string]string) 119 | } 120 | 121 | r.Labels.AdditionalProperties[tenantLabel] = tenantID 122 | rg.Rules[i] = r 123 | default: 124 | return fmt.Errorf("failed to convert rule type: %#v", r) 125 | } 126 | } 127 | 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /api/logs/v1/rules_labels_enforcer_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/efficientgo/core/testutil" 8 | "github.com/prometheus/prometheus/model/labels" 9 | ) 10 | 11 | func TestEnforceNamespaceLabels(t *testing.T) { 12 | tt := []struct { 13 | desc string 14 | keys []string 15 | accessMatchers []*labels.Matcher 16 | namespaceLabels string 17 | expectedQuery string 18 | }{ 19 | { 20 | desc: "empty_keys", 21 | namespaceLabels: "kubernetes_namespace_name=last-ns-name", 22 | expectedQuery: "kubernetes_namespace_name=last-ns-name", 23 | }, 24 | { 25 | desc: "single_key_no_matcher", 26 | keys: []string{"kubernetes_namespace_name"}, 27 | namespaceLabels: "kubernetes_namespace_name=last-ns-name", 28 | expectedQuery: "kubernetes_namespace_name=last-ns-name", 29 | }, 30 | { 31 | desc: "single_key_wrong_matcher", 32 | keys: []string{"kubernetes_namespace_name"}, 33 | accessMatchers: []*labels.Matcher{ 34 | { 35 | Type: labels.MatchEqual, 36 | Name: "kubernetes_pod_name", 37 | Value: "pod-name-.*", 38 | }, 39 | }, 40 | namespaceLabels: "kubernetes_namespace_name=last-ns-name", 41 | expectedQuery: "kubernetes_namespace_name=last-ns-name", 42 | }, 43 | { 44 | desc: "single_key_matching_matcher_wrong_value", 45 | keys: []string{"kubernetes_namespace_name"}, 46 | accessMatchers: []*labels.Matcher{ 47 | { 48 | Type: labels.MatchEqual, 49 | Name: "kubernetes_namespace_name", 50 | Value: "first-ns-name", 51 | }, 52 | }, 53 | namespaceLabels: "kubernetes_namespace_name=last-ns-name", 54 | expectedQuery: "kubernetes_namespace_name=last-ns-name", 55 | }, 56 | { 57 | desc: "single_key_matching_matcher_matching_value_wrong_type", 58 | keys: []string{"kubernetes_namespace_name"}, 59 | accessMatchers: []*labels.Matcher{ 60 | { 61 | Type: labels.MatchNotEqual, 62 | Name: "kubernetes_namespace_name", 63 | Value: "last-ns-name", 64 | }, 65 | }, 66 | namespaceLabels: "kubernetes_namespace_name=last-ns-name", 67 | expectedQuery: "kubernetes_namespace_name=last-ns-name", 68 | }, 69 | { 70 | desc: "single_key_matching_matcher_matching_value", 71 | keys: []string{"kubernetes_namespace_name"}, 72 | accessMatchers: []*labels.Matcher{ 73 | { 74 | Type: labels.MatchEqual, 75 | Name: "kubernetes_namespace_name", 76 | Value: "last-ns-name", 77 | }, 78 | }, 79 | namespaceLabels: "kubernetes_namespace_name=last-ns-name", 80 | expectedQuery: "labels=kubernetes_namespace_name:last-ns-name", 81 | }, 82 | { 83 | desc: "query with a single key with multiple occurrences", 84 | keys: []string{"kubernetes_namespace_name"}, 85 | accessMatchers: []*labels.Matcher{ 86 | { 87 | Type: labels.MatchRegexp, 88 | Name: "kubernetes_namespace_name", 89 | Value: "ns-name|another-ns-name", 90 | }, 91 | }, 92 | namespaceLabels: "kubernetes_namespace_name=ns-name&kubernetes_namespace_name=another-ns-name", 93 | expectedQuery: "labels=kubernetes_namespace_name:ns-name", 94 | }, 95 | { 96 | desc: "query_with_multiple_keys_with_single_occurrences", 97 | keys: []string{"kubernetes_namespace_name", "kubernetes_pod_name"}, 98 | accessMatchers: []*labels.Matcher{ 99 | { 100 | Type: labels.MatchEqual, 101 | Name: "kubernetes_namespace_name", 102 | Value: "ns-name", 103 | }, 104 | { 105 | Type: labels.MatchEqual, 106 | Name: "kubernetes_pod_name", 107 | Value: "my-pod", 108 | }, 109 | }, 110 | namespaceLabels: "kubernetes_namespace_name=ns-name&kubernetes_pod_name=my-pod", 111 | expectedQuery: "labels=kubernetes_namespace_name:ns-name,kubernetes_pod_name:my-pod", 112 | }, 113 | { 114 | desc: "query_with_multiple_keys_with_single_occurrences_but_only_one_matcher", 115 | keys: []string{"kubernetes_namespace_name", "kubernetes_pod_name"}, 116 | accessMatchers: []*labels.Matcher{ 117 | { 118 | Type: labels.MatchEqual, 119 | Name: "kubernetes_namespace_name", 120 | Value: "ns-name", 121 | }, 122 | }, 123 | namespaceLabels: "kubernetes_namespace_name=ns-name&kubernetes_pod_name=my-pod", 124 | expectedQuery: "kubernetes_pod_name=my-pod&labels=kubernetes_namespace_name:ns-name", 125 | }, 126 | { 127 | desc: "query_with_multiple_keys_with_multiple_occurrences", 128 | keys: []string{"kubernetes_namespace_name", "kubernetes_pod_name"}, 129 | accessMatchers: []*labels.Matcher{ 130 | { 131 | Type: labels.MatchRegexp, 132 | Name: "kubernetes_namespace_name", 133 | Value: "ns-name|ns-new-name", 134 | }, 135 | { 136 | Type: labels.MatchRegexp, 137 | Name: "kubernetes_pod_name", 138 | Value: "my-pod|my-new-pod", 139 | }, 140 | }, 141 | namespaceLabels: "kubernetes_namespace_name=ns-name&kubernetes_pod_name=my-pod&kubernetes_namespace_name=ns-new-name&kubernetes_pod_name=my-new-pod", 142 | expectedQuery: "labels=kubernetes_namespace_name:ns-name,kubernetes_pod_name:my-pod", 143 | }, 144 | } 145 | 146 | for _, tc := range tt { 147 | t.Run(tc.desc, func(t *testing.T) { 148 | t.Parallel() 149 | 150 | matchers, err := initAuthzMatchers(tc.accessMatchers) 151 | testutil.Ok(t, err) 152 | 153 | queryValues, err := url.ParseQuery(tc.namespaceLabels) 154 | testutil.Ok(t, err) 155 | 156 | v := transformParametersInLabelFilter(tc.keys, matchers, queryValues) 157 | 158 | ac, err := url.QueryUnescape(v) 159 | testutil.Ok(t, err) 160 | testutil.Equals(t, tc.expectedQuery, ac) 161 | }) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /api/logs/v1/testdata/alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "success", 3 | "data": { 4 | "alerts": [ 5 | { 6 | "labels": { 7 | "alertname": "HighPercentageErrorAAA", 8 | "namespace": "log-test-0", 9 | "severity": "critical", 10 | "tenantId": "application" 11 | }, 12 | "annotations": { 13 | "description": "High Log Test Errors", 14 | "summary": "High Log Test Errors" 15 | }, 16 | "state": "pending", 17 | "activeAt": "2023-06-01T15:19:21.177797693Z", 18 | "value": "3.898305084745763e-01" 19 | }, 20 | { 21 | "labels": { 22 | "alertname": "HighPercentageError", 23 | "namespace": "log-test-0", 24 | "severity": "critical", 25 | "tenantId": "application" 26 | }, 27 | "annotations": { 28 | "description": "High Log Test Errors", 29 | "summary": "High Log Test Errors" 30 | }, 31 | "state": "firing", 32 | "activeAt": "2023-06-01T15:18:49.705316346Z", 33 | "value": "4.203389830508475e-01" 34 | }, 35 | { 36 | "labels": { 37 | "alertname": "HighPercentageError", 38 | "namespace": "log-test-1", 39 | "severity": "critical", 40 | "tenantId": "application" 41 | }, 42 | "annotations": { 43 | "description": "High Log Test Errors", 44 | "summary": "High Log Test Errors" 45 | }, 46 | "state": "firing", 47 | "activeAt": "2023-06-01T15:18:36.80636328Z", 48 | "value": "4.3050847457627117e-01" 49 | } 50 | ] 51 | }, 52 | "errorType": "", 53 | "error": "" 54 | } 55 | -------------------------------------------------------------------------------- /api/logs/v1/testdata/rules-log-test-0.json: -------------------------------------------------------------------------------- 1 | {"status":"success","data":{"groups":[{"name":"LogTestErrors10m","file":"log-test-0-log-test-0-717f2906-d28f-4175-9597-f3a40ed64936.yaml","rules":[{"name":"application:logtest0:errors:rate10m","query":"sum(rate({kubernetes_namespace_name=\"log-test-0\", kubernetes_pod_name=~\"logger.*\"} |= \"error\"[10m]))","labels":{"namespace":"log-test-0"},"health":"ok","lastError":"","lastEvaluation":"2023-05-31T16:02:33.697904344Z","evaluationTime":0.002222441,"type":"recording"},{"name":"application:logtest1:errors:rate10m","query":"sum(rate({kubernetes_namespace_name=\"log-test-0\", kubernetes_pod_name=~\"logger.*\"} |= \"error\"[10m]))","labels":{"namespace":"log-test-0"},"health":"unknown","lastError":"","lastEvaluation":"0001-01-01T00:00:00Z","evaluationTime":0,"type":"recording"}],"interval":600,"lastEvaluation":"2023-05-31T16:02:33.697883585Z","evaluationTime":0.002247922},{"name":"LogTestAAA","file":"log-test-0-log-test-0-8221e8bb-4cab-4f21-adad-1d197aaf0dee.yaml","rules":[{"state":"inactive","name":"HighPercentageErrorAAA","query":"((sum(rate({kubernetes_namespace_name=\"log-test-0\", kubernetes_pod_name=~\"logger.*\"} |= \"error\"[1m])) / sum(rate({kubernetes_namespace_name=\"log-test-0\", kubernetes_pod_name=~\"logger.*\"}[1m]))) u003e 0.01)","duration":10,"labels":{"namespace":"log-test-0","severity":"critical","tenantId":"application"},"annotations":{"description":"High Log Test Errors","summary":"High Log Test Errors"},"alerts":[],"health":"ok","lastError":"","lastEvaluation":"2023-05-31T16:03:22.223755769Z","evaluationTime":0.002617669,"type":"alerting"}],"interval":60,"lastEvaluation":"2023-05-31T16:03:22.223732169Z","evaluationTime":0.002646403}]},"error":"","errorType":""} 2 | -------------------------------------------------------------------------------- /api/logs/v1/testdata/rules.yaml: -------------------------------------------------------------------------------- 1 | log-test-0-log-test-0-5d36c48b-c5d4-477d-a565-a0c4513c29f1.yaml: 2 | - name: LogTest 3 | interval: 1m 4 | rules: 5 | - alert: HighPercentageError 6 | expr: | 7 | sum(rate({kubernetes_namespace_name="log-test-0", kubernetes_pod_name=~"logger.*"} |= "error" [1m])) 8 | / 9 | sum(rate({kubernetes_namespace_name="log-test-0", kubernetes_pod_name=~"logger.*"}[1m])) 10 | > 0.01 11 | for: 10s 12 | labels: 13 | namespace: log-test-0 14 | severity: critical 15 | tenantId: application 16 | annotations: 17 | description: High Log Test Errors 18 | summary: High Log Test Errors 19 | - name: LogTestAAA 20 | interval: 1m 21 | rules: 22 | - alert: HighPercentageErrorAAA 23 | expr: | 24 | sum(rate({kubernetes_namespace_name="log-test-0", kubernetes_pod_name=~"logger.*"} |= "error" [1m])) 25 | / 26 | sum(rate({kubernetes_namespace_name="log-test-0", kubernetes_pod_name=~"logger.*"}[1m])) 27 | > 0.01 28 | for: 10s 29 | labels: 30 | namespace: log-test-0 31 | severity: critical 32 | tenantId: application 33 | annotations: 34 | description: High Log Test Errors 35 | summary: High Log Test Errors 36 | log-test-1-log-test-1-b00ddae9-26ed-46dc-a8f4-b46a8f9e7a14.yaml: 37 | - name: LogTest 38 | interval: 1m 39 | rules: 40 | - alert: HighPercentageError 41 | expr: | 42 | sum(rate({kubernetes_namespace_name="log-test-1", kubernetes_pod_name=~"logger.*"} |= "error" [1m])) 43 | / 44 | sum(rate({kubernetes_namespace_name="log-test-1", kubernetes_pod_name=~"logger.*"}[1m])) 45 | > 0.01 46 | for: 10s 47 | labels: 48 | namespace: log-test-1 49 | severity: critical 50 | tenantId: application 51 | annotations: 52 | description: High Log Test Errors 53 | summary: High Log Test Errors 54 | -------------------------------------------------------------------------------- /api/metrics/v1/alertmanager_enforcer.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/observatorium/api/authentication" 12 | "github.com/observatorium/api/httperr" 13 | "github.com/prometheus/alertmanager/api/v2/models" 14 | amlabels "github.com/prometheus/alertmanager/pkg/labels" 15 | "github.com/prometheus/prometheus/model/labels" 16 | ) 17 | 18 | // WithEnforceTenancyOnFilter returns a middleware that ensures that every filter has a tenant label enforced. 19 | func WithEnforceTenancyOnFilter(label string) func(http.Handler) http.Handler { 20 | return func(next http.Handler) http.Handler { 21 | // https://github.com/prometheus-community/prom-label-proxy/ 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | id, ok := authentication.GetTenantID(r.Context()) 24 | if !ok { 25 | httperr.PrometheusAPIError(w, "error finding tenant ID", http.StatusInternalServerError) 26 | 27 | return 28 | } 29 | 30 | matcher := &labels.Matcher{ 31 | Name: label, 32 | Type: labels.MatchEqual, 33 | Value: id, 34 | } 35 | matcherStr := matcher.String() 36 | 37 | q := r.URL.Query() 38 | filters := q["filter"] 39 | modified := []string{matcherStr} 40 | 41 | if len(filters) == 0 { 42 | q.Set("filter", matcherStr) 43 | } else { 44 | 45 | for _, filter := range filters { 46 | m, err := amlabels.ParseMatcher(filter) 47 | if err != nil { 48 | return 49 | } 50 | // Keep the original matcher in case of multi label values because 51 | // the user might want to filter on a specific value. 52 | if m.Name == label { 53 | continue 54 | } 55 | modified = append(modified, filter) 56 | } 57 | } 58 | 59 | q["filter"] = modified 60 | q.Del(label) 61 | r.URL.RawQuery = q.Encode() 62 | next.ServeHTTP(w, r) 63 | }) 64 | } 65 | } 66 | 67 | // WithEnforceTenancyOnFilter returns a middleware that ensures that every filter has a tenant label enforced. 68 | func WithEnforceTenancyOnSilenceMatchers(label string) func(http.Handler) http.Handler { 69 | return func(next http.Handler) http.Handler { 70 | // https://github.com/prometheus-community/prom-label-proxy/ 71 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 72 | id, ok := authentication.GetTenantID(r.Context()) 73 | if !ok { 74 | httperr.PrometheusAPIError(w, "error finding tenant ID", http.StatusInternalServerError) 75 | return 76 | } 77 | 78 | if r.Method != http.MethodPost { 79 | httperr.PrometheusAPIError(w, "error method not allowed", http.StatusMethodNotAllowed) 80 | return 81 | } 82 | 83 | var ( 84 | sil models.PostableSilence 85 | ) 86 | 87 | if err := json.NewDecoder(r.Body).Decode(&sil); err != nil { 88 | httperr.PrometheusAPIError(w, fmt.Sprintf("bad request: can't decode: %v", err), http.StatusBadRequest) 89 | return 90 | } 91 | 92 | if sil.ID != "" { 93 | // This is an update for an existing silence. 94 | httperr.PrometheusAPIError(w, "updates to silence by ID not allowed", http.StatusUnprocessableEntity) 95 | } 96 | 97 | var falsy bool 98 | modified := models.Matchers{ 99 | &models.Matcher{Name: &(label), Value: &id, IsRegex: &falsy}, 100 | } 101 | for _, m := range sil.Matchers { 102 | if m.Name != nil && *m.Name == label { 103 | continue 104 | } 105 | modified = append(modified, m) 106 | } 107 | 108 | // At least one matcher in addition to the enforced label is required, 109 | // otherwise all alerts would be silenced 110 | if len(modified) < 2 { 111 | httperr.PrometheusAPIError(w, "need at least one matcher, got none", http.StatusBadRequest) 112 | return 113 | } 114 | sil.Matchers = modified 115 | 116 | var buf bytes.Buffer 117 | if err := json.NewEncoder(&buf).Encode(&sil); err != nil { 118 | httperr.PrometheusAPIError(w, fmt.Sprintf("can't encode: %v", err), http.StatusInternalServerError) 119 | return 120 | } 121 | 122 | r = r.Clone(r.Context()) 123 | r.Body = io.NopCloser(&buf) 124 | r.URL.RawQuery = "" 125 | r.Header["Content-Length"] = []string{strconv.Itoa(buf.Len())} 126 | r.ContentLength = int64(buf.Len()) 127 | next.ServeHTTP(w, r) 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /api/traces/v1/api.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/go-kit/log" 8 | "github.com/go-kit/log/level" 9 | grpcproxy "github.com/mwitkow/grpc-proxy/proxy" 10 | "github.com/observatorium/api/tls" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/credentials" 13 | "google.golang.org/grpc/credentials/insecure" 14 | ) 15 | 16 | // TraceRoute represents the fully-qualified gRPC method name for exporting a trace. 17 | const TraceRoute = "/opentelemetry.proto.collector.trace.v1.TraceService/Export" 18 | 19 | type connOptions struct { 20 | logger log.Logger 21 | tlsOptions *tls.UpstreamOptions 22 | } 23 | 24 | // ClientOption modifies the connection's configuration. 25 | type ClientOption func(h *connOptions) 26 | 27 | // WithLogger add a custom logger for the handler to use. 28 | func WithLogger(logger log.Logger) ClientOption { 29 | return func(h *connOptions) { 30 | h.logger = logger 31 | } 32 | } 33 | 34 | func WithUpstreamTLSOptions(tlsOptions *tls.UpstreamOptions) ClientOption { 35 | return func(h *connOptions) { 36 | h.tlsOptions = tlsOptions 37 | } 38 | } 39 | 40 | func newCredentials(tlsOptions *tls.UpstreamOptions) credentials.TransportCredentials { 41 | tlsConfig := tlsOptions.NewClientConfig() 42 | if tlsConfig == nil { 43 | return insecure.NewCredentials() 44 | } 45 | return credentials.NewTLS(tlsConfig) 46 | } 47 | 48 | // NewOTelConnection creates new GRPC connection to OTel handler. 49 | func NewOTelConnection(write string, opts ...ClientOption) (*grpc.ClientConn, error) { 50 | c := &connOptions{ 51 | logger: log.NewNopLogger(), 52 | } 53 | 54 | for _, o := range opts { 55 | o(c) 56 | } 57 | 58 | // The endpoint is typically an OTel collector, but can be any gRPC 59 | // service supporting opentelemetry.proto.collector.trace.v1.TraceService 60 | level.Info(c.logger).Log("msg", "gRPC dialing OTel", "endpoint", write) 61 | 62 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 63 | defer cancel() 64 | 65 | return grpc.DialContext(ctx, write, // nolint: staticcheck 66 | // Note that CustomCodec() is deprecated. The fix for this isn't calling WithDefaultCallOptions(ForceCodec(...)) as suggested, 67 | // because the codec we need to register is also deprecated. A better fix, is the newer 68 | // version of mwitkow/grpc-proxy, but that version doesn't (currently) work with OTel protocol. 69 | grpc.WithCodec(grpcproxy.Codec()), // nolint: staticcheck 70 | grpc.WithTransportCredentials(newCredentials(c.tlsOptions))) // nolint: staticcheck 71 | } 72 | -------------------------------------------------------------------------------- /authentication/authentication_test.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/go-kit/log" 9 | grpc_middleware_auth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" 10 | "github.com/mitchellh/mapstructure" 11 | "github.com/observatorium/api/logger" 12 | "github.com/prometheus/client_golang/prometheus" 13 | "google.golang.org/grpc" 14 | ) 15 | 16 | type dummyAuthenticator struct { 17 | tenant string 18 | logger log.Logger 19 | Config dummyAuthenticatorConfig 20 | } 21 | 22 | type dummyAuthenticatorConfig struct { 23 | Name string `json:"name"` 24 | } 25 | 26 | func (a dummyAuthenticator) Middleware() Middleware { 27 | return func(next http.Handler) http.Handler { 28 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 | type key string 30 | const authenticatedKey key = "authenticated" 31 | ctx := context.WithValue(r.Context(), authenticatedKey, true) 32 | next.ServeHTTP(w, r.WithContext(ctx)) 33 | }) 34 | } 35 | } 36 | 37 | func (a dummyAuthenticator) GRPCMiddleware() grpc.StreamServerInterceptor { 38 | return grpc_middleware_auth.StreamServerInterceptor(func(ctx context.Context) (context.Context, error) { 39 | type key string 40 | const authenticatedKey key = "authenticated" 41 | return context.WithValue(ctx, authenticatedKey, true), nil 42 | }) 43 | } 44 | 45 | func (a dummyAuthenticator) Handler() (string, http.Handler) { 46 | return "", nil 47 | } 48 | 49 | func newdummyAuthenticator(c map[string]interface{}, tenant string, registrationRetryCount *prometheus.CounterVec, logger log.Logger) (Provider, error) { 50 | var config dummyAuthenticatorConfig 51 | 52 | err := mapstructure.Decode(c, &config) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | return &dummyAuthenticator{ 58 | tenant: tenant, 59 | logger: logger, 60 | Config: config, 61 | }, nil 62 | } 63 | 64 | func TestNewAuthentication(t *testing.T) { 65 | authenticatorTypeName := "dummyAuthenticator" 66 | dummyConfig := map[string]interface{}{"name": "test"} 67 | l := logger.NewLogger("info", logger.LogFormatLogfmt, "") 68 | 69 | // Register the authenticator factory 70 | providerFactories[authenticatorTypeName] = newdummyAuthenticator 71 | 72 | tenant := "test-tenant" 73 | 74 | reg := prometheus.NewRegistry() 75 | registrationFailingMetric := RegisterTenantsFailingMetric(reg) 76 | pm := NewProviderManager(l, registrationFailingMetric) 77 | 78 | t.Run("Getting an authenticator factory", func(t *testing.T) { 79 | _, err := getProviderFactory(authenticatorTypeName) 80 | if err != nil { 81 | t.Fatalf("unexpected error: %s", err) 82 | } 83 | _, err = getProviderFactory("unregistered-authenticator") 84 | if err == nil { 85 | t.Fatalf("getting an authenticator factory of unregistered authenticator should fail") 86 | } 87 | }) 88 | 89 | t.Run("initialize authenticators", func(t *testing.T) { 90 | initializedProvider := <-pm.InitializeProvider(dummyConfig, tenant, authenticatorTypeName, registrationFailingMetric, l) 91 | if initializedProvider == nil { 92 | t.Fatalf("initialized authenticator should not be nil") 93 | } 94 | 95 | _, ok := pm.Middlewares(tenant) 96 | if !ok { 97 | t.Fatalf("middleware of the dummy authenticator has not been found") 98 | } 99 | 100 | _, handler := initializedProvider.Handler() 101 | if handler != nil { 102 | t.Fatalf("getting undefined handler should be nil") 103 | } 104 | 105 | nonExistantProvider := <-pm.InitializeProvider(dummyConfig, tenant, "not-exist", registrationFailingMetric, l) 106 | if nonExistantProvider != nil { 107 | t.Fatalf("intializing a non-exist authenticator should return a nil authenticator") 108 | } 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /authentication/grpc.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/go-kit/log" 8 | grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware/v2" 9 | grpc_middleware_auth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/codes" 12 | _ "google.golang.org/grpc/encoding/gzip" // Allow GRPC to handle GZipped streams 13 | "google.golang.org/grpc/metadata" 14 | "google.golang.org/grpc/status" 15 | ) 16 | 17 | // GRPCMiddlewareFunc is a function type able to return authentication middleware for 18 | // a given tenant. If no middleware is found, the second return value should be false. 19 | type GRPCMiddlewareFunc func(tenant string) (grpc.StreamServerInterceptor, bool) 20 | 21 | // WithGRPCTenantHeader returns a new StreamServerInterceptor that adds the tenant and tenantID 22 | // to the stream Context. 23 | func WithGRPCTenantHeader(header string, tenantIDs map[string]string, _ log.Logger) grpc.StreamServerInterceptor { 24 | header = strings.ToLower(header) 25 | 26 | return func(srv interface{}, ss grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler) error { 27 | ctx := ss.Context() 28 | md, ok := metadata.FromIncomingContext(ctx) 29 | if !ok { 30 | return status.Errorf(codes.Internal, "metadata not found") 31 | } 32 | 33 | headerTenants := md[header] 34 | if len(headerTenants) == 0 { 35 | return status.Errorf(codes.InvalidArgument, "header %q not set", header) 36 | } 37 | if len(headerTenants) > 1 { 38 | return status.Errorf(codes.InvalidArgument, "header %q requested multiple tenants", header) 39 | } 40 | 41 | tenant := headerTenants[0] 42 | ctx = context.WithValue(ctx, tenantKey, tenant) 43 | 44 | id, ok := tenantIDs[tenant] 45 | if !ok { 46 | // This lets unauthenticated users know the tenant is invalid, but can't be helped, as we 47 | // can't validate the bearer token until we know the tenant. (An alternative is to return 48 | // codes.Unauthenticated and not explain about the tenant if this is a concern.) 49 | return status.Error(codes.InvalidArgument, "unknown tenant") 50 | } 51 | // The tenant header contains the tenant name. 52 | // It needs to be overridden to send the ID to match the HTTP functionality from openshift.WithTenantHeader. 53 | md.Set(header, id) 54 | ctx = metadata.NewIncomingContext(ctx, md) 55 | ctx = context.WithValue(ctx, tenantIDKey, id) 56 | 57 | wrapped := grpc_middleware.WrapServerStream(ss) 58 | wrapped.WrappedContext = ctx 59 | 60 | return handler(srv, wrapped) 61 | } 62 | } 63 | 64 | func WithGRPCAccessToken() grpc.StreamServerInterceptor { 65 | return grpc_middleware_auth.StreamServerInterceptor(func(ctx context.Context) (context.Context, error) { 66 | md, ok := metadata.FromIncomingContext(ctx) 67 | if !ok { 68 | return nil, status.Error(codes.Internal, "metadata error") 69 | } 70 | rawTokens := md["authorization"] 71 | if len(rawTokens) == 0 { 72 | return ctx, status.Error(codes.Unauthenticated, "error no access token") 73 | } 74 | rawToken := rawTokens[len(rawTokens)-1] 75 | token := rawToken[strings.LastIndex(rawToken, " ")+1:] 76 | return context.WithValue(ctx, accessTokenKey, token), nil 77 | }) 78 | } 79 | 80 | // WithGRPCTenantInterceptors creates a single Middleware for all 81 | // provided tenant-middleware sets. 82 | func WithGRPCTenantInterceptors(logger log.Logger, mwFns ...GRPCMiddlewareFunc) grpc.StreamServerInterceptor { 83 | return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { 84 | tenant, ok := GetTenant(ss.Context()) 85 | if !ok { 86 | return status.Error(codes.InvalidArgument, "error finding tenant") 87 | } 88 | 89 | for _, mwFn := range mwFns { 90 | if m, ok := mwFn(tenant); ok { 91 | return m(srv, ss, info, handler) 92 | } 93 | } 94 | 95 | return status.Error(codes.PermissionDenied, "tenant not found, have you registered it?") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /authentication/metrics.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "github.com/prometheus/client_golang/prometheus/promauto" 6 | ) 7 | 8 | func RegisterTenantsFailingMetric(reg prometheus.Registerer) *prometheus.CounterVec { 9 | return promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ 10 | Namespace: "observatorium", 11 | Subsystem: "api", 12 | Name: "tenants_failed_registrations_total", 13 | Help: "The number of failed provider instantiations.", 14 | }, []string{"tenant", "provider"}) 15 | } 16 | -------------------------------------------------------------------------------- /authentication/mtls.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/go-kit/log" 13 | grpc_middleware_auth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" 14 | "github.com/mitchellh/mapstructure" 15 | "github.com/observatorium/api/httperr" 16 | "github.com/prometheus/client_golang/prometheus" 17 | "google.golang.org/grpc" 18 | "google.golang.org/grpc/codes" 19 | "google.golang.org/grpc/status" 20 | ) 21 | 22 | // MTLSAuthenticatorType represents the mTLS authentication provider type. 23 | const MTLSAuthenticatorType = "mtls" 24 | 25 | func init() { 26 | onboardNewProvider(MTLSAuthenticatorType, newMTLSAuthenticator) 27 | } 28 | 29 | type mTLSConfig struct { 30 | RawCA []byte `json:"ca"` 31 | CAPath string `json:"caPath"` 32 | CAs []*x509.Certificate 33 | } 34 | 35 | type MTLSAuthenticator struct { 36 | tenant string 37 | logger log.Logger 38 | config *mTLSConfig 39 | } 40 | 41 | func newMTLSAuthenticator(c map[string]interface{}, tenant string, registrationRetryCount *prometheus.CounterVec, logger log.Logger) (Provider, error) { 42 | var config mTLSConfig 43 | 44 | err := mapstructure.Decode(c, &config) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | if config.CAPath != "" { 50 | rawCA, err := os.ReadFile(config.CAPath) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to read mTLS ca file: %s", err.Error()) 53 | } 54 | 55 | config.RawCA = rawCA 56 | 57 | var ( 58 | block *pem.Block 59 | rest []byte = rawCA 60 | cert *x509.Certificate 61 | cas []*x509.Certificate 62 | ) 63 | 64 | for { 65 | block, rest = pem.Decode(rest) 66 | if block == nil { 67 | return nil, fmt.Errorf("failed to parse CA certificate PEM") 68 | } 69 | 70 | cert, err = x509.ParseCertificate(block.Bytes) 71 | if err != nil { 72 | return nil, fmt.Errorf("failed to parse CA certificate: %v", err) 73 | } 74 | 75 | cas = append(cas, cert) 76 | 77 | if len(rest) == 0 { 78 | break 79 | } 80 | } 81 | 82 | config.CAs = cas 83 | } 84 | 85 | return MTLSAuthenticator{ 86 | tenant: tenant, 87 | logger: logger, 88 | config: &config, 89 | }, nil 90 | } 91 | 92 | func (a MTLSAuthenticator) Middleware() Middleware { 93 | return func(next http.Handler) http.Handler { 94 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 95 | caPool := x509.NewCertPool() 96 | for _, ca := range a.config.CAs { 97 | caPool.AddCert(ca) 98 | } 99 | 100 | if len(r.TLS.PeerCertificates) == 0 { 101 | httperr.PrometheusAPIError(w, "no client certificate presented", http.StatusUnauthorized) 102 | return 103 | } 104 | 105 | opts := x509.VerifyOptions{ 106 | Roots: caPool, 107 | Intermediates: x509.NewCertPool(), 108 | KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, 109 | } 110 | 111 | if len(r.TLS.PeerCertificates) > 1 { 112 | for _, cert := range r.TLS.PeerCertificates[1:] { 113 | opts.Intermediates.AddCert(cert) 114 | } 115 | } 116 | 117 | if _, err := r.TLS.PeerCertificates[0].Verify(opts); err != nil { 118 | if errors.Is(err, x509.CertificateInvalidError{}) { 119 | httperr.PrometheusAPIError(w, err.Error(), http.StatusUnauthorized) 120 | return 121 | } 122 | httperr.PrometheusAPIError(w, err.Error(), http.StatusInternalServerError) 123 | return 124 | } 125 | 126 | var sub string 127 | switch { 128 | case len(r.TLS.PeerCertificates[0].EmailAddresses) > 0: 129 | sub = r.TLS.PeerCertificates[0].EmailAddresses[0] 130 | case len(r.TLS.PeerCertificates[0].URIs) > 0: 131 | sub = r.TLS.PeerCertificates[0].URIs[0].String() 132 | case len(r.TLS.PeerCertificates[0].DNSNames) > 0: 133 | sub = r.TLS.PeerCertificates[0].DNSNames[0] 134 | case len(r.TLS.PeerCertificates[0].IPAddresses) > 0: 135 | sub = r.TLS.PeerCertificates[0].IPAddresses[0].String() 136 | default: 137 | httperr.PrometheusAPIError(w, "could not determine subject", http.StatusBadRequest) 138 | return 139 | } 140 | ctx := context.WithValue(r.Context(), subjectKey, sub) 141 | 142 | // Add organizational units as groups. 143 | ctx = context.WithValue(ctx, groupsKey, r.TLS.PeerCertificates[0].Subject.OrganizationalUnit) 144 | 145 | next.ServeHTTP(w, r.WithContext(ctx)) 146 | }) 147 | } 148 | } 149 | 150 | func (a MTLSAuthenticator) GRPCMiddleware() grpc.StreamServerInterceptor { 151 | return grpc_middleware_auth.StreamServerInterceptor(func(ctx context.Context) (context.Context, error) { 152 | return ctx, status.Error(codes.Unimplemented, "internal error") 153 | }) 154 | } 155 | 156 | func (a MTLSAuthenticator) Handler() (string, http.Handler) { 157 | return "", nil 158 | } 159 | -------------------------------------------------------------------------------- /authentication/openshift/cookie.go: -------------------------------------------------------------------------------- 1 | package openshift 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "fmt" 9 | "io" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // Cipher implements a custom block cipher to 15 | // encprypt cookie values based on a given key. 16 | type Cipher struct { 17 | cipher.Block 18 | } 19 | 20 | // NewCipher returns a new AES-based block cipher. 21 | // On failure initializing the AES cipher for the 22 | // given secret it returns an error. 23 | func NewCipher(secret []byte) (*Cipher, error) { 24 | c, err := aes.NewCipher(secret) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return &Cipher{Block: c}, nil 30 | } 31 | 32 | // Encrypt returns the base64 encoded version of the encrypted string. 33 | func (c *Cipher) Encrypt(value string) (string, error) { 34 | ciphertext := make([]byte, aes.BlockSize+len(value)) 35 | 36 | iv := ciphertext[:aes.BlockSize] 37 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 38 | return "", errors.Wrap(err, "failed to create initialization vector") 39 | } 40 | 41 | stream := cipher.NewCFBEncrypter(c.Block, iv) 42 | stream.XORKeyStream(ciphertext[aes.BlockSize:], []byte(value)) 43 | 44 | return base64.StdEncoding.EncodeToString(ciphertext), nil 45 | } 46 | 47 | // Decrypt returns the original encrypted string. 48 | func (c *Cipher) Decrypt(s string) (string, error) { 49 | encrypted, err := base64.StdEncoding.DecodeString(s) 50 | if err != nil { 51 | return "", errors.Wrap(err, "failed to decrypt cookie value %s") 52 | } 53 | 54 | if len(encrypted) < aes.BlockSize { 55 | return "", fmt.Errorf("encrypted cookie value should be "+ 56 | "at least %d bytes, but is only %d bytes", 57 | aes.BlockSize, len(encrypted)) 58 | } 59 | 60 | iv := encrypted[:aes.BlockSize] 61 | encrypted = encrypted[aes.BlockSize:] 62 | stream := cipher.NewCFBDecrypter(c.Block, iv) 63 | stream.XORKeyStream(encrypted, encrypted) 64 | 65 | return string(encrypted), nil 66 | } 67 | -------------------------------------------------------------------------------- /authentication/openshift/discovery.go: -------------------------------------------------------------------------------- 1 | package openshift 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | const ( 14 | oauthWellKnownPath = "/.well-known/oauth-authorization-server" 15 | 16 | // ServiceAccountNamespacePath is the path to the default serviceaccount namespace. 17 | ServiceAccountNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" 18 | // ServiceAccountTokenPath is the path to the default serviceaccount token. 19 | ServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec 20 | // ServiceAccountCAPath is the path to the default cluster CA certificate. 21 | ServiceAccountCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" 22 | ) 23 | 24 | // GetServiceAccountCACert returns the PEM-encoded CA certificate currently mounted. 25 | func GetServiceAccountCACert() ([]byte, error) { 26 | rawCA, err := os.ReadFile(ServiceAccountCAPath) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return rawCA, nil 32 | } 33 | 34 | // DiscoverCredentials returns the clientID and clientSecret credentials for a 35 | // serviceaccount name. Returns an error if the reading the auto-mounted files for 36 | // the namespace and token are not readable. 37 | func DiscoverCredentials(name string) (string, string, error) { 38 | n, err := os.ReadFile(ServiceAccountNamespacePath) 39 | if err != nil || len(n) == 0 { 40 | return "", "", err 41 | } 42 | 43 | d, err := os.ReadFile(ServiceAccountTokenPath) 44 | if err != nil || len(d) == 0 { 45 | return "", "", err 46 | } 47 | 48 | clientID := fmt.Sprintf("system:serviceaccount:%s:%s", strings.TrimSpace(string(n)), name) 49 | clientSecret := strings.TrimSpace(string(d)) 50 | 51 | return clientID, clientSecret, nil 52 | } 53 | 54 | // DiscoverOAuth return the authorization and token endpoints of the OpenShift OAuth server. 55 | // Returns an error if requesting the `/.well-known/oauth-authorization-server` fails. 56 | // nolint:intefacer 57 | func DiscoverOAuth(client *http.Client) (authURL *url.URL, tokenURL *url.URL, err error) { 58 | oauthURL := toKubeAPIURLWithPath(oauthWellKnownPath) 59 | 60 | req, err := http.NewRequest(http.MethodGet, oauthURL.String(), nil) 61 | if err != nil { 62 | return nil, nil, fmt.Errorf("unable to create request to oauth server: %w", err) 63 | } 64 | 65 | resp, err := client.Do(req) 66 | if err != nil { 67 | return nil, nil, fmt.Errorf("unable to send request to oauth server: %w", err) 68 | } 69 | defer resp.Body.Close() 70 | 71 | body, err := io.ReadAll(resp.Body) 72 | if err != nil { 73 | return nil, nil, fmt.Errorf("failed to read response body: %w", err) 74 | } 75 | 76 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 77 | return nil, nil, fmt.Errorf("got %d %s", resp.StatusCode, body) 78 | } 79 | 80 | var oauthResp struct { 81 | AuthURL string `json:"authorization_endpoint"` 82 | TokenURL string `json:"token_endpoint"` 83 | } 84 | 85 | err = json.Unmarshal(body, &oauthResp) 86 | if err != nil { 87 | return nil, nil, fmt.Errorf("unable to unmarshal response: %w", err) 88 | } 89 | 90 | authURL, err = url.Parse(oauthResp.AuthURL) 91 | if err != nil { 92 | return nil, nil, fmt.Errorf("unable to parse authorization endpoint URL: %w", err) 93 | } 94 | 95 | tokenURL, err = url.Parse(oauthResp.TokenURL) 96 | if err != nil { 97 | return nil, nil, fmt.Errorf("unable to parse token endpoint URL: %w", err) 98 | } 99 | 100 | return authURL, tokenURL, nil 101 | } 102 | 103 | func toKubeAPIURLWithPath(path string) *url.URL { 104 | ret := &url.URL{ 105 | Scheme: "https", 106 | Host: "kubernetes.default.svc", 107 | Path: path, 108 | } 109 | 110 | if host := os.Getenv("KUBERNETES_SERVICE_HOST"); len(host) > 0 { 111 | // assume IPv6 if host contains colons 112 | if strings.IndexByte(host, ':') != -1 { 113 | host = "[" + host + "]" 114 | } 115 | 116 | ret.Host = host 117 | } 118 | 119 | return ret 120 | } 121 | -------------------------------------------------------------------------------- /authentication/var.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | const ( 4 | state = "I love Observatorium" 5 | ) 6 | -------------------------------------------------------------------------------- /authorization/grpc.go: -------------------------------------------------------------------------------- 1 | package authorization 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-kit/log" 7 | "github.com/go-kit/log/level" 8 | grpc_middleware_auth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" 9 | "github.com/observatorium/api/authentication" 10 | "github.com/observatorium/api/rbac" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/codes" 13 | "google.golang.org/grpc/status" 14 | ) 15 | 16 | // AccessRequirement holds a permission for a particular resource type. 17 | type AccessRequirement struct { 18 | Permission rbac.Permission 19 | // Resource is typically "logs", "metrics", or "traces" 20 | Resource string 21 | } 22 | 23 | // GRPCRBac represents the RBAC requirements for a particular fully-qualified gRPC Method. 24 | // For example, "opentelemetry.proto.collector.trace.v1.TraceService/Export" 25 | // requires "write" permission for "traces". 26 | type GRPCRBac map[string]AccessRequirement 27 | 28 | // WithGRPCAuthorizers is the gRPC version of WithAuthorizers. 29 | func WithGRPCAuthorizers(authorizers map[string]rbac.Authorizer, methReq GRPCRBac, logger log.Logger) grpc_middleware_auth.AuthFunc { 30 | return func(ctx context.Context) (context.Context, error) { 31 | fullMethodName, ok := grpc.Method(ctx) 32 | if !ok { 33 | return ctx, status.Error(codes.Internal, "fullMethodName not in context") 34 | } 35 | 36 | accessReq, ok := methReq[fullMethodName] 37 | if !ok { 38 | return ctx, status.Error(codes.PermissionDenied, "method never permitted") 39 | } 40 | 41 | tenant, ok := authentication.GetTenant(ctx) 42 | if !ok { 43 | return ctx, status.Error(codes.Internal, "error finding tenant") 44 | } 45 | 46 | subject, ok := authentication.GetSubject(ctx) 47 | if !ok { 48 | return ctx, status.Error(codes.PermissionDenied, "unknown subject") 49 | } 50 | 51 | groups, ok := authentication.GetGroups(ctx) 52 | if !ok { 53 | groups = []string{} 54 | } 55 | a, ok := authorizers[tenant] 56 | if !ok { 57 | return ctx, status.Error(codes.Unauthenticated, "error finding tenant") 58 | } 59 | 60 | token, ok := authentication.GetAccessToken(ctx) 61 | if !ok { 62 | return ctx, status.Error(codes.Unauthenticated, "error finding access token") 63 | } 64 | 65 | tenantID, ok := authentication.GetTenantID(ctx) 66 | if !ok { 67 | return ctx, status.Error(codes.Unauthenticated, "error finding tenant id") 68 | } 69 | 70 | _, ok, data := a.Authorize(subject, groups, accessReq.Permission, accessReq.Resource, tenant, tenantID, token, nil) 71 | if !ok { 72 | level.Debug(logger).Log("msg", "gRPC Authorizer: insufficient auth", "subject", subject, "tenant", tenant) 73 | return ctx, status.Error(codes.PermissionDenied, "forbidden") 74 | } 75 | 76 | return context.WithValue(ctx, authorizationDataKey, data), nil 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /authorization/meta.go: -------------------------------------------------------------------------------- 1 | package authorization 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | var ( 8 | metaAbsolutePaths = map[string]bool{ 9 | "/loki/api/v1/label": true, 10 | "/loki/api/v1/labels": true, 11 | "/loki/api/v1/series": true, 12 | "/api/prom/label": true, 13 | "/api/prom/series": true, 14 | } 15 | 16 | metaPathLabelValuesNewPrefix = "/loki/api/v1/label/" 17 | metaPathLabelValuesOldPrefix = "/api/prom/label/" 18 | metaPathLabelValuesSuffix = "/values" 19 | ) 20 | 21 | func isMetadataRequest(path string) bool { 22 | if absolutePath := metaAbsolutePaths[path]; absolutePath { 23 | return true 24 | } 25 | 26 | if (strings.HasPrefix(path, metaPathLabelValuesOldPrefix) || strings.HasPrefix(path, metaPathLabelValuesNewPrefix)) && 27 | strings.HasSuffix(path, metaPathLabelValuesSuffix) { 28 | return true 29 | } 30 | 31 | return false 32 | } 33 | -------------------------------------------------------------------------------- /authorization/meta_test.go: -------------------------------------------------------------------------------- 1 | package authorization 2 | 3 | import "testing" 4 | 5 | func TestIsMetaRequest(t *testing.T) { 6 | tests := []struct { 7 | path string 8 | want bool 9 | }{ 10 | { 11 | path: "/loki/api/v1/labels", 12 | want: true, 13 | }, 14 | { 15 | path: "/loki/api/v1/label/kubernetes_namespace_name/values", 16 | want: true, 17 | }, 18 | { 19 | path: "/loki/api/v1/query_range", 20 | want: false, 21 | }, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.path, func(t *testing.T) { 25 | if got := isMetadataRequest(tt.path); got != tt.want { 26 | t.Errorf("isMetaRequest() = %v, want %v", got, tt.want) 27 | } 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /authorization/query.go: -------------------------------------------------------------------------------- 1 | package authorization 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | logqlv2 "github.com/observatorium/api/logql/v2" 9 | "github.com/prometheus/prometheus/model/labels" 10 | ) 11 | 12 | func extractLogStreamSelectors(selectorNames map[string]bool, values url.Values, param string) (*SelectorsInfo, error) { 13 | value := values.Get(param) 14 | 15 | selectors, hasWildcard, err := parseLogStreamSelectors(selectorNames, value) 16 | if err != nil { 17 | return nil, fmt.Errorf("error extracting selectors from %s %#q: %w", param, value, err) 18 | } 19 | 20 | return &SelectorsInfo{ 21 | Selectors: selectors, 22 | HasWildcard: hasWildcard, 23 | }, nil 24 | } 25 | 26 | func parseLogStreamSelectors(selectorNames map[string]bool, query string) (map[string][]string, bool, error) { 27 | expr, err := logqlv2.ParseExpr(query) 28 | if err != nil { 29 | return nil, false, err 30 | } 31 | 32 | selectors := make(map[string][]string) 33 | appendSelector := func(selector, value string) { 34 | values, ok := selectors[selector] 35 | if !ok { 36 | values = make([]string, 0) 37 | } 38 | 39 | values = append(values, value) 40 | selectors[selector] = values 41 | } 42 | 43 | hasWildcard := false 44 | expr.Walk(func(expr interface{}) { 45 | switch le := expr.(type) { 46 | case *logqlv2.StreamMatcherExpr: 47 | for _, m := range le.Matchers() { 48 | if _, ok := selectorNames[m.Name]; !ok { 49 | continue 50 | } 51 | 52 | switch m.Type { 53 | case labels.MatchEqual: 54 | appendSelector(m.Name, m.Value) 55 | case labels.MatchRegexp: 56 | values := strings.Split(m.Value, "|") 57 | for _, v := range values { 58 | if strings.ContainsAny(v, ".+*") { 59 | hasWildcard = true 60 | continue 61 | } 62 | 63 | appendSelector(m.Name, v) 64 | } 65 | } 66 | } 67 | default: 68 | // Do nothing 69 | } 70 | }) 71 | 72 | return selectors, hasWildcard, nil 73 | } 74 | -------------------------------------------------------------------------------- /authorization/query_test.go: -------------------------------------------------------------------------------- 1 | package authorization 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func Test_parseQuerySelectors(t *testing.T) { 9 | testSelectorLabels := map[string]bool{ 10 | "namespace": true, 11 | "other_namespace_label": true, 12 | } 13 | tests := []struct { 14 | query string 15 | wantSelectors map[string][]string 16 | wantHasWildcard bool 17 | }{ 18 | { 19 | query: `{namespace="test"}`, 20 | wantSelectors: map[string][]string{ 21 | "namespace": {"test"}, 22 | }, 23 | }, 24 | { 25 | query: `{namespace="test",other_namespace_label="test2"}`, 26 | wantSelectors: map[string][]string{ 27 | "namespace": {"test"}, 28 | "other_namespace_label": {"test2"}, 29 | }, 30 | }, 31 | { 32 | query: `{namespace="test",namespace="test2"}`, 33 | wantSelectors: map[string][]string{ 34 | "namespace": {"test", "test2"}, 35 | }, 36 | }, 37 | { 38 | query: `{namespace=~"test|test2"}`, 39 | wantSelectors: map[string][]string{ 40 | "namespace": {"test", "test2"}, 41 | }, 42 | }, 43 | { 44 | query: `{namespace=~"test|test2|test3.+"}`, 45 | wantSelectors: map[string][]string{ 46 | "namespace": {"test", "test2"}, 47 | }, 48 | wantHasWildcard: true, 49 | }, 50 | { 51 | query: `({namespace="test"})`, 52 | wantSelectors: map[string][]string{ 53 | "namespace": {"test"}, 54 | }, 55 | }, 56 | { 57 | query: `(count_over_time({namespace="test"}[2m]) > 10)`, 58 | wantSelectors: map[string][]string{ 59 | "namespace": {"test"}, 60 | }, 61 | }, 62 | } 63 | for _, tt := range tests { 64 | t.Run(tt.query, func(t *testing.T) { 65 | gotNamespaces, gotHasWildcard, err := parseLogStreamSelectors(testSelectorLabels, tt.query) 66 | if err != nil { 67 | t.Errorf("parseLogStreamSelectors() error = %v", err) 68 | } 69 | if !reflect.DeepEqual(gotNamespaces, tt.wantSelectors) { 70 | t.Errorf("parseLogStreamSelectors() got = %v, want %v", gotNamespaces, tt.wantSelectors) 71 | } 72 | if gotHasWildcard != tt.wantHasWildcard { 73 | t.Errorf("parseLogStreamSelectors() got = %v, want %v", gotHasWildcard, tt.wantHasWildcard) 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /authorization/rules.go: -------------------------------------------------------------------------------- 1 | package authorization 2 | 3 | import ( 4 | "net/url" 5 | ) 6 | 7 | func extractLogRulesSelectors(selectorNames map[string]bool, values url.Values) *SelectorsInfo { 8 | return &SelectorsInfo{ 9 | Selectors: parseLogRulesSelectors(selectorNames, values), 10 | } 11 | } 12 | 13 | func parseLogRulesSelectors(selectorNames map[string]bool, values url.Values) map[string][]string { 14 | selectors := make(map[string][]string) 15 | appendSelector := func(selector, value string) { 16 | values, ok := selectors[selector] 17 | if !ok { 18 | values = make([]string, 0) 19 | } 20 | 21 | values = append(values, value) 22 | selectors[selector] = values 23 | } 24 | 25 | for selector := range selectorNames { 26 | values := values[selector] 27 | for _, value := range values { 28 | appendSelector(selector, value) 29 | } 30 | } 31 | 32 | return selectors 33 | } 34 | -------------------------------------------------------------------------------- /authorization/rules_test.go: -------------------------------------------------------------------------------- 1 | package authorization 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/efficientgo/core/testutil" 9 | ) 10 | 11 | func Test_parseQueryParametersSelectors(t *testing.T) { 12 | testSelectorLabels := map[string]bool{ 13 | "namespace": true, 14 | "other_namespace_label": true, 15 | } 16 | tests := []struct { 17 | queryParameters string 18 | wantSelectors map[string][]string 19 | }{ 20 | { 21 | queryParameters: `namespace=test`, 22 | wantSelectors: map[string][]string{ 23 | "namespace": {"test"}, 24 | }, 25 | }, 26 | { 27 | queryParameters: `namespace=test&other_namespace_label=test2`, 28 | wantSelectors: map[string][]string{ 29 | "namespace": {"test"}, 30 | "other_namespace_label": {"test2"}, 31 | }, 32 | }, 33 | { 34 | queryParameters: `namespace=test&namespace=test2`, 35 | wantSelectors: map[string][]string{ 36 | "namespace": {"test", "test2"}, 37 | }, 38 | }, 39 | } 40 | for _, tt := range tests { 41 | t.Run(tt.queryParameters, func(t *testing.T) { 42 | queryValues, err := url.ParseQuery(tt.queryParameters) 43 | testutil.Ok(t, err) 44 | 45 | gotNamespaces := parseLogRulesSelectors(testSelectorLabels, queryValues) 46 | if !reflect.DeepEqual(gotNamespaces, tt.wantSelectors) { 47 | t.Errorf("parseLogStreamSelectors() got = %v, want %v", gotNamespaces, tt.wantSelectors) 48 | } 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /build/conditional-container-push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | usage() { 4 | echo "Usage: $0 IMAGE_URI" >&2 5 | exit 1 6 | } 7 | 8 | ## image_exists_in_repo IMAGE_URI 9 | # 10 | # Checks whether IMAGE_URI -- e.g. quay.io/app-sre/osd-metrics-exporter:abcd123 11 | # -- exists in the remote repository. 12 | # If so, returns success. 13 | # If the image does not exist, but the query was otherwise successful, returns 14 | # failure. 15 | # If the query fails for any reason, prints an error and *exits* nonzero. 16 | # 17 | # This function cribbed from: 18 | # https://github.com/openshift/boilerplate/blob/0ba6566d544d0df9993a92b2286c131eb61f3e88/boilerplate/_lib/common.sh#L77-L135 19 | # ...then adapted to use docker rather than skopeo. 20 | image_exists_in_repo() { 21 | local image_uri=$1 22 | local output 23 | local rc 24 | local skopeo_stderr 25 | 26 | skopeo_stderr=$(mktemp) 27 | output=$(docker image pull "${image_uri}" 2>"$skopeo_stderr") 28 | rc=$? 29 | # So we can delete the temp file right away... 30 | stderr=$(cat "$skopeo_stderr") 31 | rm -f "$skopeo_stderr" 32 | if [[ $rc -eq 0 ]]; then 33 | # The image exists. Sanity check the output. 34 | local report 35 | if report=$(docker image inspect "${image_uri}" 2>&1); then 36 | local digest 37 | digest=$(jq -r '.[].RepoDigests[0]' <<< "$report") 38 | if [[ "$digest" != *@* ]]; then 39 | echo "Unexpected error: docker inspect succeeded, but output contained no digest." 40 | echo "Here's the inspect output:" 41 | echo "$report" 42 | echo "...and stderr:" 43 | echo "$stderr" 44 | exit 1 45 | fi 46 | # Happy path: image exists 47 | echo "Image ${image_uri} exists with digest $digest." 48 | return 0 49 | fi 50 | echo "Unexpected error: docker inspect failed after docker pull succeeded." 51 | echo "Here's the output:" 52 | echo "$report" 53 | exit 1 54 | elif [[ "$stderr" == *"manifest for"*"not found"* ]]; then 55 | # We were able to talk to the repository, but the tag doesn't exist. 56 | # This is the normal "green field" case. 57 | echo "Image ${image_uri} does not exist in the repository." 58 | return 1 59 | elif [[ "$stderr" == *"was deleted or has expired"* ]]; then 60 | # This should be rare, but accounts for cases where we had to 61 | # manually delete an image. 62 | echo "Image ${image_uri} was deleted from the repository." 63 | echo "Proceeding as if it never existed." 64 | return 1 65 | else 66 | # Any other error. For example: 67 | # - "unauthorized: access to the requested resource is not 68 | # authorized". This happens not just on auth errors, but if we 69 | # reference a repository that doesn't exist. 70 | # - "no such host". 71 | # - Network or other infrastructure failures. 72 | # In all these cases, we want to bail, because we don't know whether 73 | # the image exists (and we'd likely fail to push it anyway). 74 | echo "Error querying the repository for ${image_uri}:" 75 | echo "stdout: $output" 76 | echo "stderr: $stderr" 77 | exit 1 78 | fi 79 | } 80 | 81 | set -exv 82 | 83 | IMAGE_URI=$1 84 | [[ -z "$IMAGE_URI" ]] && usage 85 | 86 | # NOTE(efried): Since we reference images by digest, rebuilding an image 87 | # with the same tag can be Bad. This is because the digest calculation 88 | # includes metadata such as date stamp, meaning that even though the 89 | # contents may be identical, the digest may change. In this situation, 90 | # the original digest URI no longer has any tags referring to it, so the 91 | # repository deletes it. This can break existing deployments referring 92 | # to the old digest. We could have solved this issue by generating a 93 | # permanent tag tied to each digest. We decided to do it this way 94 | # instead. 95 | # For testing purposes, if you need to force the build/push to rerun, 96 | # delete the image at $IMAGE_URI. 97 | if image_exists_in_repo "$IMAGE_URI"; then 98 | echo "Image ${IMAGE_URI} already exists. Nothing to do!" 99 | exit 0 100 | fi 101 | 102 | make container-build-push 103 | -------------------------------------------------------------------------------- /client/parameters/parameters.gen.go: -------------------------------------------------------------------------------- 1 | // Package parameters provides primitives to interact with the openapi HTTP API. 2 | // 3 | // Code generated by github.com/deepmap/oapi-codegen version v1.9.0 DO NOT EDIT. 4 | package parameters 5 | 6 | // EndTS defines model for endTS. 7 | type EndTS string 8 | 9 | // Limit defines model for limit. 10 | type Limit float32 11 | 12 | // LogRulesGroup defines model for logRulesGroup. 13 | type LogRulesGroup string 14 | 15 | // LogRulesNamespace defines model for logRulesNamespace. 16 | type LogRulesNamespace string 17 | 18 | // LogqlQuery defines model for logqlQuery. 19 | type LogqlQuery string 20 | 21 | // OptionalSeriesMatcher defines model for optionalSeriesMatcher. 22 | type OptionalSeriesMatcher []string 23 | 24 | // PromqlQuery defines model for promqlQuery. 25 | type PromqlQuery string 26 | 27 | // QueryDedup defines model for queryDedup. 28 | type QueryDedup bool 29 | 30 | // QueryPartialResponse defines model for queryPartialResponse. 31 | type QueryPartialResponse bool 32 | 33 | // QueryTimeout defines model for queryTimeout. 34 | type QueryTimeout string 35 | 36 | // SeriesMatcher defines model for seriesMatcher. 37 | type SeriesMatcher []string 38 | 39 | // StartTS defines model for startTS. 40 | type StartTS string 41 | 42 | // Tenant defines model for tenant. 43 | type Tenant string 44 | -------------------------------------------------------------------------------- /client/parameters/parameters.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | parameters: 3 | tenant: 4 | in: path 5 | name: tenant 6 | description: name of the tenant 7 | required: true 8 | schema: 9 | type: string 10 | seriesMatcher: 11 | in: query 12 | name: match[] 13 | description: Repeated series selector argument 14 | required: true 15 | schema: 16 | type: array 17 | items: 18 | type: string 19 | optionalSeriesMatcher: 20 | in: query 21 | name: match[] 22 | description: Repeated series selector argument 23 | schema: 24 | type: array 25 | items: 26 | type: string 27 | startTS: 28 | in: query 29 | name: start 30 | description: Start timestamp 31 | schema: 32 | type: string 33 | format: rfc3339 | unix_timestamp 34 | endTS: 35 | in: query 36 | name: end 37 | description: End timestamp 38 | schema: 39 | type: string 40 | format: rfc3339 | unix_timestamp 41 | promqlQuery: 42 | in: query 43 | name: query 44 | description: PromQL query to fetch result for metrics 45 | schema: 46 | type: string 47 | logqlQuery: 48 | in: query 49 | name: query 50 | description: LogQL query to fetch result for logs 51 | schema: 52 | type: string 53 | queryTimeout: 54 | in: query 55 | name: timeout 56 | description: Evaluation timeout 57 | schema: 58 | type: string 59 | limit: 60 | in: query 61 | name: limit 62 | description: Max number of entries 63 | schema: 64 | type: number 65 | queryDedup: 66 | in: query 67 | name: dedup 68 | description: Query deduplication (Thanos) 69 | schema: 70 | type: boolean 71 | queryPartialResponse: 72 | in: query 73 | name: partial_response 74 | description: Query partial response (Thanos) 75 | schema: 76 | type: boolean 77 | logRulesNamespace: 78 | in: path 79 | name: namespace 80 | description: namespace of the log rule group 81 | required: true 82 | schema: 83 | type: string 84 | logRulesGroup: 85 | in: path 86 | name: group 87 | description: group of the log rule 88 | required: true 89 | schema: 90 | type: string 91 | -------------------------------------------------------------------------------- /docs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/.gitkeep -------------------------------------------------------------------------------- /docs/benchmark.md: -------------------------------------------------------------------------------- 1 | # Benchmark baseline results 2 | 3 | This document contains baseline benchmark results for Observatorium API under synthetic load. 4 | 5 | Tested on: 6 | 7 | ```txt 8 | (15-inch, 2019) 9 | 2,6 GHz 6-Core Intel Core i7 10 | 16 GB 2400 MHz DDR4 11 | ``` 12 | 13 | Generated using: 14 | 15 | [embedmd]:# (../tmp/load_help.txt) 16 | ```txt 17 | load.sh [-h] [-r n] [-c n] [-m n] [-q n] [-o csv|gnuplot] -- program to test synthetic load on observatorium api and report results. 18 | 19 | where: 20 | -h show this help text 21 | -r set number of seconds to run (default: 300) 22 | -c set number of cluster to simulate (default: 5000) 23 | -m set number of machines per cluster to simulate (default: 2) 24 | -q set number of concurrent queries to execute (default: 10) 25 | -o set the output format (default: csv. options: csv, gnuplot) 26 | ``` 27 | 28 | With parameters: 29 | 30 | ```console 31 | $ ./test/load.sh -r 300 -c 1000 -m 3 -q 10 -o gnuplot 32 | ``` 33 | 34 | > It runs tets for 5 minutes, simulating 3000 machines sending metrics and 10 consumers querying for their data every second. 35 | > Observatorim API GW runs in-front of a mock provider which always responds with a successful response. 36 | 37 | ## Results 38 | 39 | Most relevant results are the ones on resource consumption. 40 | CPU usage is pretty much stable. 41 | Memory usage correlates with the number of goroutines, which correlates the number of open connections. 42 | Memory usage increases and request latencies increase as the backend services' load increase, which is expected. 43 | 44 | ### Resource consumption 45 | 46 | #### CPU Usage 47 | 48 | > `rate(process_cpu_seconds_total{job="observatorium"}[1m]) * 1000` 49 | 50 | ![./loadtests/cpu.png](./loadtests/cpu.png) 51 | 52 | #### Memory Usage 53 | 54 | > `process_resident_memory_bytes{job="observatorium"}'` 55 | 56 | ![./loadtests/mem.png](./loadtests/mem.png) 57 | 58 | #### Number of Goroutines 59 | 60 | > go_goroutines{job="observatorium"}' 61 | 62 | ![./loadtests/goroutines.png](./loadtests/goroutines.png) 63 | 64 | ### Latencies 65 | 66 | #### Write Latency Percentiles 67 | 68 | ##### Write P99 69 | 70 | > histogram_quantile(0.99, sum by (job, le) (rate(http_request_duration_seconds_bucket{job="observatorium", handler="write"}[1m])))' 71 | 72 | ![./loadtests/write_dur_99.png](./loadtests/write_dur_99.png) 73 | 74 | ##### Write P50 75 | 76 | > histogram_quantile(0.50, sum by (job, le) (rate(http_request_duration_seconds_bucket{job="observatorium", handler="write"}[1m])))' 77 | 78 | ![./loadtests/write_dur_50.png](./loadtests/write_dur_50.png) 79 | 80 | ##### Write Average 81 | 82 | > 100 * (sum by (job) (rate(http_request_duration_seconds_sum{job="observatorium", handler="write"}[1m])) * 100 83 | > / 84 | > sum by (job) (rate(http_request_duration_seconds_count{job="observatorium", handler="write"}[1m])))' 85 | 86 | ![./loadtests/write_dur_avg.png](./loadtests/write_dur_avg.png) 87 | 88 | #### Query Range Latency Quartiles 89 | 90 | ##### Query P99 91 | 92 | > histogram_quantile(0.99, sum by (job, le) (rate(http_request_duration_seconds_bucket{job="observatorium", handler="query_range"}[1m])))' 93 | 94 | ![./loadtests/query_range_dur_99.png](./loadtests/query_range_dur_99.png) 95 | 96 | ##### Query P50 97 | 98 | > histogram_quantile(0.50, sum by (job, le) (rate(http_request_duration_seconds_bucket{job="observatorium", handler="query_range"}[1m])))' 99 | 100 | ![./loadtests/query_range_dur_50.png](./loadtests/query_range_dur_50.png) 101 | 102 | ##### Query Average 103 | > 100 * (sum by (job) (rate(http_request_duration_seconds_sum{job="observatorium", handler="query_range"}[1m])) 104 | > / 105 | > sum by (job) (rate(http_request_duration_seconds_count{job="observatorium", handler="query_range"}[1m])))' 106 | 107 | ![./loadtests/query_range_dur_avg.png](./loadtests/query_range_dur_avg.png) 108 | -------------------------------------------------------------------------------- /docs/loadtests/cpu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/loadtests/cpu.png -------------------------------------------------------------------------------- /docs/loadtests/goroutines.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/loadtests/goroutines.png -------------------------------------------------------------------------------- /docs/loadtests/mem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/loadtests/mem.png -------------------------------------------------------------------------------- /docs/loadtests/query_range_dur_50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/loadtests/query_range_dur_50.png -------------------------------------------------------------------------------- /docs/loadtests/query_range_dur_99.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/loadtests/query_range_dur_99.png -------------------------------------------------------------------------------- /docs/loadtests/query_range_dur_avg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/loadtests/query_range_dur_avg.png -------------------------------------------------------------------------------- /docs/loadtests/results/cpu.gnuplot: -------------------------------------------------------------------------------- 1 | set grid 2 | set key left top 3 | set xdata time 4 | set timefmt '%s' 5 | set datafile separator ',' 6 | $DATA << EOD 7 | 1585752378,117.48917473535796 8 | 1585752392,272.64770391701995 9 | 1585752406,521.2563589822473 10 | 1585752420,834.3165416719918 11 | 1585752434,980.4586207480976 12 | 1585752448,1039.187769076918 13 | 1585752462,959.6935489338622 14 | 1585752476,813.3760444738225 15 | 1585752490,729.6115517592028 16 | 1585752504,713.0145924783486 17 | 1585752518,731.1740478974933 18 | 1585752532,759.257690026269 19 | 1585752546,784.5496762821599 20 | 1585752560,831.2005288225225 21 | 1585752574,905.0847457627117 22 | 1585752588,909.676435193817 23 | 1585752602,873.7140048473756 24 | 1585752616,812.3453442256197 25 | 1585752630,726.9121909055468 26 | 1585752644,790.209671678221 27 | 1585752658,1003.7628394182854 28 | EOD 29 | plot $DATA using 1:2 with lines lw 1 title '\{instance\="localhost:8080",job\="observatorium"\}' 30 | 31 | -------------------------------------------------------------------------------- /docs/loadtests/results/goroutines.gnuplot: -------------------------------------------------------------------------------- 1 | set grid 2 | set key left top 3 | set xdata time 4 | set timefmt '%s' 5 | set datafile separator ',' 6 | $DATA << EOD 7 | 1585752378,20 8 | 1585752392,35 9 | 1585752406,40 10 | 1585752420,40 11 | 1585752434,27 12 | 1585752448,27 13 | 1585752462,31 14 | 1585752476,29 15 | 1585752490,30 16 | 1585752504,29 17 | 1585752518,38 18 | 1585752532,29 19 | 1585752546,31 20 | 1585752560,35 21 | 1585752574,33 22 | 1585752588,31 23 | 1585752602,27 24 | 1585752616,27 25 | 1585752630,29 26 | 1585752644,45 27 | 1585752658,66 28 | EOD 29 | plot $DATA using 1:2 with lines lw 1 title 'go\_goroutines\{instance\="localhost:8080",job\="observatorium"\}' 30 | 31 | -------------------------------------------------------------------------------- /docs/loadtests/results/mem.gnuplot: -------------------------------------------------------------------------------- 1 | set grid 2 | set key left top 3 | set xdata time 4 | set timefmt '%s' 5 | set datafile separator ',' 6 | $DATA << EOD 7 | 1585752378,14503936 8 | 1585752392,14688256 9 | 1585752406,15134720 10 | 1585752420,15134720 11 | 1585752434,15134720 12 | 1585752448,15134720 13 | 1585752462,15134720 14 | 1585752476,15134720 15 | 1585752490,15134720 16 | 1585752504,15134720 17 | 1585752518,15134720 18 | 1585752532,15134720 19 | 1585752546,15134720 20 | 1585752560,15134720 21 | 1585752574,15134720 22 | 1585752588,15134720 23 | 1585752602,15134720 24 | 1585752616,15134720 25 | 1585752630,15134720 26 | 1585752644,15134720 27 | 1585752658,15134720 28 | EOD 29 | plot $DATA using 1:2 with lines lw 1 title 'process\_resident\_memory\_bytes\{instance\="localhost:8080",job\="observatorium"\}' 30 | 31 | -------------------------------------------------------------------------------- /docs/loadtests/results/query_range_dur_50.gnuplot: -------------------------------------------------------------------------------- 1 | set grid 2 | set key left top 3 | set xdata time 4 | set timefmt '%s' 5 | set datafile separator ',' 6 | $DATA << EOD 7 | 1585752378,0.05 8 | 1585752392,0.05 9 | 1585752406,0.05 10 | 1585752420,0.05019157088122605 11 | 1585752434,0.05017301038062286 12 | 1585752448,0.05052910052910053 13 | 1585752462,0.05053859964093358 14 | 1585752476,0.050538599640933574 15 | 1585752490,0.05035149384885765 16 | 1585752504,0.05025996533795495 17 | 1585752518,0.05 18 | 1585752532,0.05 19 | 1585752546,0.05 20 | 1585752560,0.05 21 | 1585752574,0.05 22 | 1585752588,0.05 23 | 1585752602,0.05 24 | 1585752616,0.05 25 | 1585752630,0.05 26 | 1585752644,0.05 27 | 1585752658,0.05 28 | EOD 29 | plot $DATA using 1:2 with lines lw 1 title '\{job\="observatorium"\}' 30 | 31 | -------------------------------------------------------------------------------- /docs/loadtests/results/query_range_dur_99.gnuplot: -------------------------------------------------------------------------------- 1 | set grid 2 | set key left top 3 | set xdata time 4 | set timefmt '%s' 5 | set datafile separator ',' 6 | $DATA << EOD 7 | 1585752378,0.09900000000000002 8 | 1585752392,0.099 9 | 1585752406,0.099 10 | 1585752420,0.09937931034482755 11 | 1585752434,0.09934256055363325 12 | 1585752448,0.10450000000000002 13 | 1585752462,0.1061666666666671 14 | 1585752476,0.10616666666666619 15 | 1585752490,0.09969595782073815 16 | 1585752504,0.09951473136915082 17 | 1585752518,0.099 18 | 1585752532,0.099 19 | 1585752546,0.09900000000000002 20 | 1585752560,0.09900000000000002 21 | 1585752574,0.099 22 | 1585752588,0.099 23 | 1585752602,0.09900000000000002 24 | 1585752616,0.099 25 | 1585752630,0.09900000000000002 26 | 1585752644,0.09899999999999999 27 | 1585752658,0.099 28 | EOD 29 | plot $DATA using 1:2 with lines lw 1 title '\{job\="observatorium"\}' 30 | 31 | -------------------------------------------------------------------------------- /docs/loadtests/results/query_range_dur_avg.gnuplot: -------------------------------------------------------------------------------- 1 | set grid 2 | set key left top 3 | set xdata time 4 | set timefmt '%s' 5 | set datafile separator ',' 6 | $DATA << EOD 7 | 1585752378,0.12798648727272735 8 | 1585752392,0.17056825400000014 9 | 1585752406,0.21328755333333338 10 | 1585752420,0.3715978791984733 11 | 1585752434,0.4227949879310346 12 | 1585752448,0.5435323958115188 13 | 1585752462,0.6312301744227357 14 | 1585752476,0.49023003339254023 15 | 1585752490,0.3725436034904003 16 | 1585752504,0.31317681465517094 17 | 1585752518,0.18872469362068814 18 | 1585752532,0.21401292568965308 19 | 1585752546,0.23170811896551635 20 | 1585752560,0.2921141818965508 21 | 1585752574,0.3014584875647671 22 | 1585752588,0.233538296752138 23 | 1585752602,0.21034673042735175 24 | 1585752616,0.15526913435897496 25 | 1585752630,0.12486771465517196 26 | 1585752644,0.1875952671794865 27 | 1585752658,0.35005238000000016 28 | EOD 29 | plot $DATA using 1:2 with lines lw 1 title '\{job\="observatorium"\}' 30 | 31 | -------------------------------------------------------------------------------- /docs/loadtests/results/write_dur_50.gnuplot: -------------------------------------------------------------------------------- 1 | set grid 2 | set key left top 3 | set xdata time 4 | set timefmt '%s' 5 | set datafile separator ',' 6 | $DATA << EOD 7 | 1585752378,0.05 8 | 1585752392,0.05 9 | 1585752406,0.05000140892696123 10 | 1585752420,0.050011273263917355 11 | 1585752434,0.050010804774658114 12 | 1585752448,0.05001867164577221 13 | 1585752462,0.05002203885891604 14 | 1585752476,0.05001464911071281 15 | 1585752490,0.05001252056950704 16 | 1585752504,0.05000628953421507 17 | 1585752518,0.05000361748693183 18 | 1585752532,0.050003569388920616 19 | 1585752546,0.05000357174747745 20 | 1585752560,0.05000360146219365 21 | 1585752574,0.05000090070615363 22 | 1585752588,0.05 23 | 1585752602,0.05 24 | 1585752616,0.05 25 | 1585752630,0.05 26 | 1585752644,0.05000551227399677 27 | 1585752658,0.05001307165132304 28 | EOD 29 | plot $DATA using 1:2 with lines lw 1 title '\{job\="observatorium"\}' 30 | 31 | -------------------------------------------------------------------------------- /docs/loadtests/results/write_dur_99.gnuplot: -------------------------------------------------------------------------------- 1 | set grid 2 | set key left top 3 | set xdata time 4 | set timefmt '%s' 5 | set datafile separator ',' 6 | $DATA << EOD 7 | 1585752378,0.099 8 | 1585752392,0.09900000000000002 9 | 1585752406,0.09900278967538322 10 | 1585752420,0.09902232106255637 11 | 1585752434,0.09902139345382306 12 | 1585752448,0.09903696985862896 13 | 1585752462,0.09904363694065377 14 | 1585752476,0.09902900523921138 15 | 1585752490,0.09902479072762395 16 | 1585752504,0.09901245327774583 17 | 1585752518,0.09900716262412501 18 | 1585752532,0.09900706739006282 19 | 1585752546,0.09900707206000536 20 | 1585752560,0.09900713089514343 21 | 1585752574,0.09900178339818419 22 | 1585752588,0.099 23 | 1585752602,0.099 24 | 1585752616,0.099 25 | 1585752630,0.099 26 | 1585752644,0.0990109143025136 27 | 1585752658,0.09902588186961964 28 | EOD 29 | plot $DATA using 1:2 with lines lw 1 title '\{job\="observatorium"\}' 30 | 31 | -------------------------------------------------------------------------------- /docs/loadtests/results/write_dur_avg.gnuplot: -------------------------------------------------------------------------------- 1 | set grid 2 | set key left top 3 | set xdata time 4 | set timefmt '%s' 5 | set datafile separator ',' 6 | $DATA << EOD 7 | 1585752378,0.05761475543856221 8 | 1585752392,0.07062155287960357 9 | 1585752406,0.1067149208458962 10 | 1585752420,0.21098617628229924 11 | 1585752434,0.21612711538332602 12 | 1585752448,0.2249135946743456 13 | 1585752462,0.20800775238531502 14 | 1585752476,0.11875562717561443 15 | 1585752490,0.08474728905976255 16 | 1585752504,0.07927705476417661 17 | 1585752518,0.08408347339712303 18 | 1585752532,0.09011839532087276 19 | 1585752546,0.09350876199397223 20 | 1585752560,0.10437348393684288 21 | 1585752574,0.11714348056492221 22 | 1585752588,0.11303413832145728 23 | 1585752602,0.10627775977036655 24 | 1585752616,0.09524163236875087 25 | 1585752630,0.08136469543389105 26 | 1585752644,0.11730293953150651 27 | 1585752658,0.2602283933426648 28 | EOD 29 | plot $DATA using 1:2 with lines lw 1 title '\{job\="observatorium"\}' 30 | 31 | -------------------------------------------------------------------------------- /docs/loadtests/write_dur_50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/loadtests/write_dur_50.png -------------------------------------------------------------------------------- /docs/loadtests/write_dur_99.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/loadtests/write_dur_99.png -------------------------------------------------------------------------------- /docs/loadtests/write_dur_avg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/docs/loadtests/write_dur_avg.png -------------------------------------------------------------------------------- /examples/dex/config-dev.yaml: -------------------------------------------------------------------------------- 1 | # The base path of dex and the external name of the OpenID Connect service. 2 | # This is the canonical URL that all clients MUST use to refer to dex. If a 3 | # path is provided, dex's HTTP service will listen at a non-root URL. 4 | issuer: http://127.0.0.1:5556/dex 5 | 6 | # The storage configuration determines where dex stores its state. Supported 7 | # options include SQL flavors and Kubernetes third party resources. 8 | # 9 | # See the storage document at Documentation/storage.md for further information. 10 | storage: 11 | type: sqlite3 12 | config: 13 | file: /tmp/dex.db 14 | 15 | # Configuration for the HTTP endpoints. 16 | web: 17 | http: 0.0.0.0:5556 18 | # Uncomment for HTTPS options. 19 | # https: 127.0.0.1:5554 20 | # tlsCert: /etc/dex/tls.crt 21 | # tlsKey: /etc/dex/tls.key 22 | 23 | # Configuration for telemetry 24 | telemetry: 25 | http: 0.0.0.0:5558 26 | 27 | # Uncomment this block to enable the gRPC API. This values MUST be different 28 | # from the HTTP endpoints. 29 | # grpc: 30 | # addr: 127.0.0.1:5557 31 | # tlsCert: examples/grpc-client/server.crt 32 | # tlsKey: examples/grpc-client/server.key 33 | # tlsClientCA: /etc/dex/client.crt 34 | 35 | # Uncomment this block to enable configuration for the expiration time durations. 36 | # expiry: 37 | # signingKeys: "6h" 38 | # idTokens: "24h" 39 | 40 | # Options for controlling the logger. 41 | logger: 42 | level: "debug" 43 | # format: "text" # can also be "json" 44 | 45 | # Default values shown below 46 | # oauth2: 47 | # use ["code", "token", "id_token"] to enable implicit flow for web-only clients 48 | # responseTypes: [ "code" ] # also allowed are "token" and "id_token" 49 | # By default, Dex will ask for approval to share data with application 50 | # (approval for sharing data from connected IdP to Dex is separate process on IdP) 51 | # skipApprovalScreen: false 52 | # If only one authentication method is enabled, the default behavior is to 53 | # go directly to it. For connected IdPs, this redirects the browser away 54 | # from application to upstream provider such as the Google login page 55 | # alwaysShowLoginScreen: false 56 | # Uncommend the passwordConnector to use a specific connector for password grants 57 | # passwordConnector: local 58 | 59 | # Instead of reading from an external storage, use this list of clients. 60 | # 61 | # If this option isn't chosen clients may be added through the gRPC API. 62 | staticClients: 63 | - id: example-app 64 | redirectURIs: 65 | - 'http://127.0.0.1:5555/callback' 66 | name: 'Example' 67 | secret: ZXhhbXBsZS1hcHAtc2VjcmV0 68 | - id: telemeter 69 | redirectURIs: 70 | - 'http://localhost:8080/oidc/telemeter/callback' 71 | name: 'Telemeter' 72 | secret: ov7zikeipai4neih7Chahcae 73 | - id: ci 74 | redirectURIs: 75 | - 'http://localhost:8080/oidc/ci/callback' 76 | name: 'CI' 77 | secret: eiqu5aaVePhaibothohshooP 78 | 79 | connectors: 80 | - type: mockCallback 81 | id: mock 82 | name: Example 83 | # - type: oidc 84 | # id: google 85 | # name: Google 86 | # config: 87 | # issuer: https://accounts.google.com 88 | # # Connector config values starting with a "$" will read from the environment. 89 | # clientID: $GOOGLE_CLIENT_ID 90 | # clientSecret: $GOOGLE_CLIENT_SECRET 91 | # redirectURI: http://127.0.0.1:5556/dex/callback 92 | # hostedDomains: 93 | # - $GOOGLE_HOSTED_DOMAIN 94 | 95 | # Let dex keep a list of passwords which can be used to login to dex. 96 | enablePasswordDB: true 97 | 98 | # A static list of passwords to login the end user. By identifying here, dex 99 | # won't look in its underlying storage for passwords. 100 | # 101 | # If this option isn't chosen users may be added through the gRPC API. 102 | staticPasswords: 103 | - email: "admin@example.com" 104 | # bcrypt hash of the string "password" 105 | hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" 106 | username: "admin" 107 | userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" 108 | -------------------------------------------------------------------------------- /examples/main.jsonnet: -------------------------------------------------------------------------------- 1 | local config = { 2 | local cfg = self, 3 | name: 'observatorium-api', 4 | namespace: 'observatorium', 5 | version: 'master-2020-09-04-v0.1.1-131-ga4c5a9c', 6 | image: 'quay.io/observatorium/api:' + cfg.version, 7 | imagePullPolicy: 'IfNotPresent', 8 | replicas: 3, 9 | metrics: { 10 | readEndpoint: 'http://127.0.0.1:9091', 11 | writeEndpoint: 'http://127.0.0.1:19291', 12 | }, 13 | logs: { 14 | readEndpoint: 'http://127.0.0.1:3100', 15 | tailEndpoint: 'http://127.0.0.1:3100', 16 | writeEndpoint: 'http://127.0.0.1:3100', 17 | }, 18 | rbac: { 19 | roles: [ 20 | { 21 | name: 'read-write', 22 | resources: [ 23 | 'metrics', 24 | ], 25 | tenants: [ 26 | 'telemeter', 27 | ], 28 | permissions: [ 29 | 'read', 30 | 'write', 31 | ], 32 | }, 33 | ], 34 | roleBindings: [ 35 | { 36 | name: 'telemeter', 37 | roles: [ 38 | 'read-write', 39 | ], 40 | subjects: [ 41 | { 42 | name: 'admin@example.com', 43 | kind: 'user', 44 | }, 45 | ], 46 | }, 47 | ], 48 | }, 49 | tenants: { 50 | tenants: [ 51 | { 52 | name: 'telemeter', 53 | id: 'FB870BF3-9F3A-44FF-9BF7-D7A047A52F43', 54 | oidc: { 55 | clientID: 'telemeter', 56 | clientSecret: 'ov7zikeipai4neih7Chahcae', 57 | issuerURL: 'http://127.0.0.1:5556/dex', 58 | redirectURL: 'http://localhost:8080/oidc/telemeter/callback', 59 | usernameClaim: 'email', 60 | }, 61 | }, 62 | ], 63 | }, 64 | }; 65 | local api = (import '../jsonnet/lib/observatorium-api.libsonnet')(config); 66 | 67 | local apiWithTLS = (import '../jsonnet/lib/observatorium-api.libsonnet')(config { 68 | tls: { 69 | certKey: 'cert', 70 | keyKey: 'key', 71 | secretName: 'observatorium-api-tls', 72 | configMapName: 'observatorium-api-tls', 73 | caKey: 'ca', 74 | reloadInterval: '1m', 75 | serverName: 'example.com', 76 | cipherSuites: 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305', 77 | }, 78 | }); 79 | 80 | { 81 | [name]: api[name] 82 | for name in std.objectFields(api) 83 | if api[name] != null 84 | } + 85 | { 86 | ['%s-with-tls' % name]: apiWithTLS[name] 87 | for name in std.objectFields(api) 88 | if apiWithTLS[name] != null 89 | } 90 | -------------------------------------------------------------------------------- /examples/manifests/configmap-with-tls.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | data: 3 | rbac.yaml: |- 4 | "roleBindings": 5 | - "name": "telemeter" 6 | "roles": 7 | - "read-write" 8 | "subjects": 9 | - "kind": "user" 10 | "name": "admin@example.com" 11 | "roles": 12 | - "name": "read-write" 13 | "permissions": 14 | - "read" 15 | - "write" 16 | "resources": 17 | - "metrics" 18 | "tenants": 19 | - "telemeter" 20 | kind: ConfigMap 21 | metadata: 22 | labels: 23 | app.kubernetes.io/component: api 24 | app.kubernetes.io/instance: observatorium-api 25 | app.kubernetes.io/name: observatorium-api 26 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 27 | name: observatorium-api 28 | namespace: observatorium 29 | -------------------------------------------------------------------------------- /examples/manifests/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | data: 3 | rbac.yaml: |- 4 | "roleBindings": 5 | - "name": "telemeter" 6 | "roles": 7 | - "read-write" 8 | "subjects": 9 | - "kind": "user" 10 | "name": "admin@example.com" 11 | "roles": 12 | - "name": "read-write" 13 | "permissions": 14 | - "read" 15 | - "write" 16 | "resources": 17 | - "metrics" 18 | "tenants": 19 | - "telemeter" 20 | kind: ConfigMap 21 | metadata: 22 | labels: 23 | app.kubernetes.io/component: api 24 | app.kubernetes.io/instance: observatorium-api 25 | app.kubernetes.io/name: observatorium-api 26 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 27 | name: observatorium-api 28 | namespace: observatorium 29 | -------------------------------------------------------------------------------- /examples/manifests/deployment-with-tls.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app.kubernetes.io/component: api 6 | app.kubernetes.io/instance: observatorium-api 7 | app.kubernetes.io/name: observatorium-api 8 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 9 | name: observatorium-api 10 | namespace: observatorium 11 | spec: 12 | replicas: 3 13 | selector: 14 | matchLabels: 15 | app.kubernetes.io/component: api 16 | app.kubernetes.io/instance: observatorium-api 17 | app.kubernetes.io/name: observatorium-api 18 | strategy: 19 | rollingUpdate: 20 | maxSurge: 0 21 | maxUnavailable: 1 22 | template: 23 | metadata: 24 | labels: 25 | app.kubernetes.io/component: api 26 | app.kubernetes.io/instance: observatorium-api 27 | app.kubernetes.io/name: observatorium-api 28 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 29 | spec: 30 | affinity: 31 | podAntiAffinity: 32 | preferredDuringSchedulingIgnoredDuringExecution: 33 | - podAffinityTerm: 34 | labelSelector: 35 | matchExpressions: 36 | - key: app.kubernetes.io/name 37 | operator: In 38 | values: 39 | - observatorium-api 40 | topologyKey: kubernetes.io/hostname 41 | weight: 100 42 | containers: 43 | - args: 44 | - --web.listen=0.0.0.0:8080 45 | - --web.internal.listen=0.0.0.0:8081 46 | - --log.level=warn 47 | - --metrics.read.endpoint=http://127.0.0.1:9091 48 | - --metrics.write.endpoint=http://127.0.0.1:19291 49 | - --logs.read.endpoint=http://127.0.0.1:3100 50 | - --logs.tail.endpoint=http://127.0.0.1:3100 51 | - --logs.write.endpoint=http://127.0.0.1:3100 52 | - --rbac.config=/etc/observatorium/rbac.yaml 53 | - --tenants.config=/etc/observatorium/tenants.yaml 54 | - --web.healthchecks.url=https://127.0.0.1:8080 55 | - --tls.server.cert-file=/var/run/tls/cert 56 | - --tls.server.key-file=/var/run/tls/key 57 | - --tls.healthchecks.server-ca-file=/var/run/tls/ca 58 | - --tls.reload-interval=1m 59 | - --tls.healthchecks.server-name=example.com 60 | - --tls.cipher-suites=TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 61 | image: quay.io/observatorium/api:master-2020-09-04-v0.1.1-131-ga4c5a9c 62 | imagePullPolicy: IfNotPresent 63 | livenessProbe: 64 | failureThreshold: 10 65 | httpGet: 66 | path: /live 67 | port: 8081 68 | scheme: HTTP 69 | periodSeconds: 30 70 | name: observatorium-api 71 | ports: 72 | - containerPort: 8090 73 | name: grpc-public 74 | - containerPort: 8081 75 | name: internal 76 | - containerPort: 8080 77 | name: public 78 | readinessProbe: 79 | failureThreshold: 12 80 | httpGet: 81 | path: /ready 82 | port: 8081 83 | scheme: HTTP 84 | periodSeconds: 5 85 | resources: {} 86 | volumeMounts: 87 | - mountPath: /etc/observatorium/rbac.yaml 88 | name: rbac 89 | readOnly: true 90 | subPath: rbac.yaml 91 | - mountPath: /etc/observatorium/tenants.yaml 92 | name: tenants 93 | readOnly: true 94 | subPath: tenants.yaml 95 | - mountPath: /var/run/tls/cert 96 | name: tls-secret 97 | readOnly: true 98 | subPath: cert 99 | - mountPath: /var/run/tls/key 100 | name: tls-secret 101 | readOnly: true 102 | subPath: key 103 | - mountPath: /var/run/tls/ca 104 | name: tls-configmap 105 | readOnly: true 106 | subPath: ca 107 | serviceAccountName: observatorium-api 108 | volumes: 109 | - configMap: 110 | name: observatorium-api 111 | name: rbac 112 | - name: tenants 113 | secret: 114 | secretName: observatorium-api 115 | - name: tls-secret 116 | secret: 117 | secretName: observatorium-api-tls 118 | - configMap: 119 | name: observatorium-api-tls 120 | name: tls-configmap 121 | -------------------------------------------------------------------------------- /examples/manifests/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app.kubernetes.io/component: api 6 | app.kubernetes.io/instance: observatorium-api 7 | app.kubernetes.io/name: observatorium-api 8 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 9 | name: observatorium-api 10 | namespace: observatorium 11 | spec: 12 | replicas: 3 13 | selector: 14 | matchLabels: 15 | app.kubernetes.io/component: api 16 | app.kubernetes.io/instance: observatorium-api 17 | app.kubernetes.io/name: observatorium-api 18 | strategy: 19 | rollingUpdate: 20 | maxSurge: 0 21 | maxUnavailable: 1 22 | template: 23 | metadata: 24 | labels: 25 | app.kubernetes.io/component: api 26 | app.kubernetes.io/instance: observatorium-api 27 | app.kubernetes.io/name: observatorium-api 28 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 29 | spec: 30 | affinity: 31 | podAntiAffinity: 32 | preferredDuringSchedulingIgnoredDuringExecution: 33 | - podAffinityTerm: 34 | labelSelector: 35 | matchExpressions: 36 | - key: app.kubernetes.io/name 37 | operator: In 38 | values: 39 | - observatorium-api 40 | topologyKey: kubernetes.io/hostname 41 | weight: 100 42 | containers: 43 | - args: 44 | - --web.listen=0.0.0.0:8080 45 | - --web.internal.listen=0.0.0.0:8081 46 | - --log.level=warn 47 | - --metrics.read.endpoint=http://127.0.0.1:9091 48 | - --metrics.write.endpoint=http://127.0.0.1:19291 49 | - --logs.read.endpoint=http://127.0.0.1:3100 50 | - --logs.tail.endpoint=http://127.0.0.1:3100 51 | - --logs.write.endpoint=http://127.0.0.1:3100 52 | - --rbac.config=/etc/observatorium/rbac.yaml 53 | - --tenants.config=/etc/observatorium/tenants.yaml 54 | image: quay.io/observatorium/api:master-2020-09-04-v0.1.1-131-ga4c5a9c 55 | imagePullPolicy: IfNotPresent 56 | livenessProbe: 57 | failureThreshold: 10 58 | httpGet: 59 | path: /live 60 | port: 8081 61 | scheme: HTTP 62 | periodSeconds: 30 63 | name: observatorium-api 64 | ports: 65 | - containerPort: 8090 66 | name: grpc-public 67 | - containerPort: 8081 68 | name: internal 69 | - containerPort: 8080 70 | name: public 71 | readinessProbe: 72 | failureThreshold: 12 73 | httpGet: 74 | path: /ready 75 | port: 8081 76 | scheme: HTTP 77 | periodSeconds: 5 78 | resources: {} 79 | volumeMounts: 80 | - mountPath: /etc/observatorium/rbac.yaml 81 | name: rbac 82 | readOnly: true 83 | subPath: rbac.yaml 84 | - mountPath: /etc/observatorium/tenants.yaml 85 | name: tenants 86 | readOnly: true 87 | subPath: tenants.yaml 88 | serviceAccountName: observatorium-api 89 | volumes: 90 | - configMap: 91 | name: observatorium-api 92 | name: rbac 93 | - name: tenants 94 | secret: 95 | secretName: observatorium-api 96 | -------------------------------------------------------------------------------- /examples/manifests/secret-with-tls.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | labels: 5 | app.kubernetes.io/component: api 6 | app.kubernetes.io/instance: observatorium-api 7 | app.kubernetes.io/name: observatorium-api 8 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 9 | name: observatorium-api 10 | namespace: observatorium 11 | stringData: 12 | tenants.yaml: |- 13 | "tenants": 14 | - "id": "FB870BF3-9F3A-44FF-9BF7-D7A047A52F43" 15 | "name": "telemeter" 16 | "oidc": 17 | "clientID": "telemeter" 18 | "clientSecret": "ov7zikeipai4neih7Chahcae" 19 | "issuerURL": "http://127.0.0.1:5556/dex" 20 | "redirectURL": "http://localhost:8080/oidc/telemeter/callback" 21 | "usernameClaim": "email" 22 | -------------------------------------------------------------------------------- /examples/manifests/secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | labels: 5 | app.kubernetes.io/component: api 6 | app.kubernetes.io/instance: observatorium-api 7 | app.kubernetes.io/name: observatorium-api 8 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 9 | name: observatorium-api 10 | namespace: observatorium 11 | stringData: 12 | tenants.yaml: |- 13 | "tenants": 14 | - "id": "FB870BF3-9F3A-44FF-9BF7-D7A047A52F43" 15 | "name": "telemeter" 16 | "oidc": 17 | "clientID": "telemeter" 18 | "clientSecret": "ov7zikeipai4neih7Chahcae" 19 | "issuerURL": "http://127.0.0.1:5556/dex" 20 | "redirectURL": "http://localhost:8080/oidc/telemeter/callback" 21 | "usernameClaim": "email" 22 | -------------------------------------------------------------------------------- /examples/manifests/service-with-tls.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app.kubernetes.io/component: api 6 | app.kubernetes.io/instance: observatorium-api 7 | app.kubernetes.io/name: observatorium-api 8 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 9 | name: observatorium-api 10 | namespace: observatorium 11 | spec: 12 | ports: 13 | - appProtocol: h2c 14 | name: grpc-public 15 | port: 8090 16 | targetPort: 8090 17 | - appProtocol: http 18 | name: internal 19 | port: 8081 20 | targetPort: 8081 21 | - appProtocol: http 22 | name: public 23 | port: 8080 24 | targetPort: 8080 25 | selector: 26 | app.kubernetes.io/component: api 27 | app.kubernetes.io/instance: observatorium-api 28 | app.kubernetes.io/name: observatorium-api 29 | -------------------------------------------------------------------------------- /examples/manifests/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app.kubernetes.io/component: api 6 | app.kubernetes.io/instance: observatorium-api 7 | app.kubernetes.io/name: observatorium-api 8 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 9 | name: observatorium-api 10 | namespace: observatorium 11 | spec: 12 | ports: 13 | - appProtocol: h2c 14 | name: grpc-public 15 | port: 8090 16 | targetPort: 8090 17 | - appProtocol: http 18 | name: internal 19 | port: 8081 20 | targetPort: 8081 21 | - appProtocol: http 22 | name: public 23 | port: 8080 24 | targetPort: 8080 25 | selector: 26 | app.kubernetes.io/component: api 27 | app.kubernetes.io/instance: observatorium-api 28 | app.kubernetes.io/name: observatorium-api 29 | -------------------------------------------------------------------------------- /examples/manifests/serviceAccount-with-tls.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/component: api 6 | app.kubernetes.io/instance: observatorium-api 7 | app.kubernetes.io/name: observatorium-api 8 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 9 | name: observatorium-api 10 | namespace: observatorium 11 | -------------------------------------------------------------------------------- /examples/manifests/serviceAccount.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/component: api 6 | app.kubernetes.io/instance: observatorium-api 7 | app.kubernetes.io/name: observatorium-api 8 | app.kubernetes.io/version: master-2020-09-04-v0.1.1-131-ga4c5a9c 9 | name: observatorium-api 10 | namespace: observatorium 11 | -------------------------------------------------------------------------------- /examples/rbac/simple.yaml: -------------------------------------------------------------------------------- 1 | roles: 2 | - name: read-write 3 | resources: 4 | - metrics 5 | tenants: 6 | - telemeter 7 | permissions: 8 | - read 9 | - write 10 | roleBindings: 11 | - name: telemeter 12 | roles: 13 | - read-write 14 | subjects: 15 | - kind: user 16 | name: admin@example.com 17 | -------------------------------------------------------------------------------- /examples/tenants/simple.yaml: -------------------------------------------------------------------------------- 1 | tenants: 2 | - name: telemeter 3 | id: FB870BF3-9F3A-44FF-9BF7-D7A047A52F43 4 | oidc: 5 | clientID: telemeter 6 | clientSecret: ov7zikeipai4neih7Chahcae 7 | issuerURL: http://127.0.0.1:5556/dex 8 | redirectURL: http://localhost:8080/oidc/telemeter/callback 9 | usernameClaim: email 10 | -------------------------------------------------------------------------------- /examples/tenants/two.yaml: -------------------------------------------------------------------------------- 1 | tenants: 2 | - name: telemeter 3 | id: FB870BF3-9F3A-44FF-9BF7-D7A047A52F43 4 | oidc: 5 | clientID: telemeter 6 | clientSecret: ov7zikeipai4neih7Chahcae 7 | issuerURL: http://127.0.0.1:5556/dex 8 | redirectURL: http://localhost:8080/oidc/telemeter/callback 9 | - name: ci 10 | id: 24464419-ebf9-456a-b4ed-13817659cefa 11 | oidc: 12 | clientID: ci 13 | clientSecret: eiqu5aaVePhaibothohshooP 14 | issuerURL: http://127.0.0.1:5556/dex 15 | redirectURL: http://localhost:8080/oidc/ci/callback 16 | -------------------------------------------------------------------------------- /httperr/httperr.go: -------------------------------------------------------------------------------- 1 | package httperr 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | // Adapted from https://github.com/prometheus-community/prom-label-proxy/blob/02d43edb82b7d139f4a4e41912ad903bff46d5c4/injectproxy/utils.go#L22 10 | func PrometheusAPIError(w http.ResponseWriter, errorMessage string, code int) { 11 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 12 | w.WriteHeader(code) 13 | 14 | // As per Prometheus HTTP API format: https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview 15 | res := map[string]string{"status": "error", "errorType": "observatorium-api", "error": errorMessage} 16 | 17 | if err := json.NewEncoder(w).Encode(res); err != nil { 18 | log.Printf("failed to encode json: %v", err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/go-kit/log" 7 | "github.com/go-kit/log/level" 8 | ) 9 | 10 | const ( 11 | LogFormatLogfmt = "logfmt" 12 | LogFormatJSON = "json" 13 | ) 14 | 15 | func NewLogger(logLevel, logFormat, debugName string) log.Logger { 16 | var ( 17 | logger log.Logger 18 | lvl level.Option 19 | ) 20 | 21 | switch logLevel { 22 | case "error": 23 | lvl = level.AllowError() 24 | case "warn": 25 | lvl = level.AllowWarn() 26 | case "info": 27 | lvl = level.AllowInfo() 28 | case "debug": 29 | lvl = level.AllowDebug() 30 | default: 31 | panic("unexpected log level") 32 | } 33 | 34 | logger = log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)) 35 | if logFormat == LogFormatJSON { 36 | logger = log.NewJSONLogger(log.NewSyncWriter(os.Stderr)) 37 | } 38 | 39 | logger = level.NewFilter(logger, lvl) 40 | 41 | if debugName != "" { 42 | logger = log.With(logger, "name", debugName) 43 | } 44 | 45 | return log.With(logger, "ts", log.DefaultTimestampUTC, "caller", log.DefaultCaller) 46 | } 47 | -------------------------------------------------------------------------------- /logql/v2/parser.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "sync" 7 | "text/scanner" 8 | ) 9 | 10 | var ( 11 | parserPool = sync.Pool{ 12 | New: func() interface{} { 13 | //nolint:exhaustivestruct 14 | return &parser{ 15 | p: &exprParserImpl{}, 16 | Reader: strings.NewReader(""), 17 | lexer: &lexer{}, 18 | } 19 | }, 20 | } 21 | 22 | errNotCompatibleParserImpl = errors.New("not compatible parser implementation") 23 | ) 24 | 25 | func init() { 26 | exprErrorVerbose = true 27 | exprDebug = 0 28 | } 29 | 30 | type parser struct { 31 | p *exprParserImpl 32 | *lexer 33 | expr Expr 34 | *strings.Reader 35 | } 36 | 37 | func ParseExpr(input string) (Expr, error) { 38 | p, ok := parserPool.Get().(*parser) 39 | if !ok { 40 | return nil, errNotCompatibleParserImpl 41 | } 42 | 43 | defer parserPool.Put(p) 44 | 45 | p.Reader.Reset(input) 46 | p.lexer.Init(p.Reader) 47 | p.lexer.errs = p.lexer.errs[:0] 48 | p.lexer.Scanner.Error = func(_ *scanner.Scanner, msg string) { 49 | p.lexer.Error(msg) 50 | } 51 | 52 | e := p.p.Parse(p) 53 | if e != 0 || len(p.lexer.errs) > 0 { 54 | return nil, p.lexer.errs[0] 55 | } 56 | 57 | return p.expr, nil 58 | } 59 | -------------------------------------------------------------------------------- /opa/opa_test.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "testing" 7 | 8 | "github.com/go-kit/log" 9 | "github.com/observatorium/api/rbac" 10 | "github.com/open-policy-agent/opa/ast" 11 | "github.com/open-policy-agent/opa/rego" 12 | "github.com/open-policy-agent/opa/types" 13 | ) 14 | 15 | func dummyCustomRegoFunction(logger log.Logger) func(*rego.Rego) { 16 | return rego.Function1( 17 | ®o.Function{ 18 | Name: "isEmailAddress", 19 | Decl: types.NewFunction(types.Args(types.A), types.B)}, 20 | func(_ rego.BuiltinContext, subject *ast.Term) (*ast.Term, error) { 21 | // Dummy check, allow only email-based subjects 22 | var validEmail = regexp.MustCompile(`^\S+@\S+\.\S+$`) 23 | return ast.BooleanTerm(validEmail.Match([]byte(subject.Value.String()))), nil 24 | }) 25 | } 26 | 27 | func TestCustomRegoFunctions(t *testing.T) { 28 | onboardNewFunction("dummy-rego-function", dummyCustomRegoFunction) 29 | 30 | dir := t.TempDir() 31 | defer os.RemoveAll(dir) 32 | 33 | regoFile, err := os.CreateTemp(dir, "test.rego") 34 | if err != nil { 35 | t.Fatalf("unexpected error: %s", err) 36 | } 37 | 38 | defer os.Remove(regoFile.Name()) 39 | 40 | regoLogic := ` 41 | package observatorium 42 | 43 | import input 44 | 45 | default allow = false 46 | 47 | allow { 48 | isEmailAddress(input.subject) 49 | } 50 | ` 51 | 52 | _, err = regoFile.Write([]byte(regoLogic)) 53 | if err != nil { 54 | t.Fatalf("unexpected error: %s", err) 55 | } 56 | 57 | authorizer, err := NewInProcessAuthorizer("data.observatorium.allow", []string{regoFile.Name()}) 58 | if err != nil { 59 | t.Fatalf("unexpected error: %s", err) 60 | } 61 | 62 | t.Run("successful authorize with rego built-in function", func(t *testing.T) { 63 | _, isPermitted, data := authorizer.Authorize("example@example.com", []string{}, rbac.Write, "logs", "dummyTenant", "dummyTenantID", "", nil) 64 | if len(data) != 0 { 65 | t.Fatalf("unexpected data: Got: %s, Wanted: %s", data, "") 66 | } 67 | 68 | if !isPermitted { 69 | t.Fatalf("unexpected permission response: Got: %t, Wanted: %t", isPermitted, true) 70 | } 71 | }) 72 | 73 | t.Run("unsuccessful authorize with rego built-in function", func(t *testing.T) { 74 | _, isPermitted, data := authorizer.Authorize("dummySubject", []string{}, rbac.Write, "logs", "dummyTenant", "dummyTenantID", "", nil) 75 | if len(data) != 0 { 76 | t.Fatalf("unexpected data: Got: %s, Wanted: %s", data, "") 77 | } 78 | if isPermitted { 79 | t.Fatalf("unexpected permission response: Got: %t, Wanted: %t", isPermitted, false) 80 | } 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | stdlog "log" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "strings" 11 | 12 | "github.com/go-chi/chi/middleware" 13 | "github.com/go-kit/log" 14 | "github.com/go-kit/log/level" 15 | "github.com/prometheus/client_golang/prometheus" 16 | ) 17 | 18 | type contextKey string 19 | 20 | const ( 21 | prefixKey contextKey = "prefix" 22 | 23 | PrefixHeader string = "X-Forwarded-Prefix" 24 | 25 | TempoOrgIDHeaderName string = "X-Scope-OrgID" 26 | ) 27 | 28 | type Middleware func(r *http.Request) 29 | 30 | func Middlewares(middlewares ...Middleware) func(r *http.Request) { 31 | return func(r *http.Request) { 32 | for _, m := range middlewares { 33 | m(r) 34 | } 35 | } 36 | } 37 | 38 | func MiddlewareRemoveURLPrefix(prefix string) Middleware { 39 | return func(r *http.Request) { 40 | r.URL.Path = fmt.Sprintf("/%s", strings.TrimLeft(strings.Trim(r.URL.Path, "/"), prefix)) 41 | } 42 | } 43 | 44 | func MiddlewareSetUpstream(upstream *url.URL) Middleware { 45 | return func(r *http.Request) { 46 | r.URL.Scheme = upstream.Scheme 47 | r.URL.Host = upstream.Host 48 | r.URL.Path = path.Join(upstream.Path, r.URL.Path) 49 | } 50 | } 51 | 52 | func MiddlewareSetPrefixHeader() Middleware { 53 | return func(r *http.Request) { 54 | prefix, ok := getPrefix(r.Context()) 55 | if !ok { 56 | return 57 | } 58 | 59 | // Do not override the prefix header if it is already set. 60 | if r.Header.Get(PrefixHeader) != "" { 61 | return 62 | } 63 | 64 | r.Header.Set(PrefixHeader, prefix) 65 | } 66 | } 67 | 68 | func MiddlewareLogger(logger log.Logger) Middleware { 69 | return func(r *http.Request) { 70 | rlogger := log.With(logger, "request", middleware.GetReqID(r.Context())) 71 | level.Debug(rlogger).Log("msg", "request to upstream", "url", r.URL.String()) 72 | } 73 | } 74 | 75 | func MiddlewareMetrics(registry *prometheus.Registry, constLabels prometheus.Labels) Middleware { 76 | requests := prometheus.NewCounterVec(prometheus.CounterOpts{ 77 | Name: "http_proxy_requests_total", 78 | Help: "Counter of proxy HTTP requests.", 79 | ConstLabels: constLabels, 80 | }, []string{"method"}) 81 | 82 | registry.MustRegister(requests) 83 | 84 | return func(r *http.Request) { 85 | requests.With(prometheus.Labels{"method": r.Method}).Inc() 86 | } 87 | } 88 | 89 | func Logger(logger log.Logger) *stdlog.Logger { 90 | return stdlog.New(log.NewStdlibAdapter(level.Warn(logger)), "", stdlog.Lshortfile) 91 | } 92 | 93 | func getPrefix(ctx context.Context) (string, bool) { 94 | value := ctx.Value(prefixKey) 95 | prefix, ok := value.(string) 96 | 97 | return prefix, ok 98 | } 99 | 100 | // WithPrefix adds the provided prefix to the request context. 101 | func WithPrefix(prefix string, next http.Handler) http.Handler { 102 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 103 | next.ServeHTTP(w, r.WithContext( 104 | context.WithValue(r.Context(), prefixKey, prefix), 105 | )) 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /ratelimit/client.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" 10 | grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus" 11 | "github.com/prometheus/client_golang/prometheus" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/credentials/insecure" 14 | 15 | "github.com/observatorium/api/ratelimit/gubernator" 16 | ) 17 | 18 | var ErrOverLimit = errors.New("over limit") 19 | 20 | type Request struct { 21 | name string 22 | Key string 23 | Limit int64 24 | // Duration is the Duration of the rate limit window in milliseconds. 25 | Duration int64 26 | failOpen bool 27 | retryAfterMin time.Duration 28 | retryAfterMax time.Duration 29 | } 30 | 31 | // Client can connect to gubernator and get rate limits. 32 | type Client struct { 33 | dialOpts []grpc.DialOption 34 | client gubernator.V1Client 35 | } 36 | 37 | type SharedRateLimiter interface { 38 | // GetRateLimits retrieves the rate limits for a given request. 39 | // It returns the remaining requests, the reset time as Unix time (millisecond from epoch), and any error that occurred. 40 | // When a rate limit is exceeded, the error errOverLimit is returned. 41 | GetRateLimits(ctx context.Context, req *Request) (remaining, resetTime int64, err error) 42 | } 43 | 44 | // NewClient creates a new gubernator client with default configuration. 45 | func NewClient(reg prometheus.Registerer) *Client { 46 | grpcMetrics := grpc_prometheus.NewClientMetrics() 47 | grpcMetrics.EnableClientHandlingTimeHistogram() 48 | dialOpts := []grpc.DialOption{ 49 | grpc.WithUnaryInterceptor( 50 | grpc_middleware.ChainUnaryClient(grpcMetrics.UnaryClientInterceptor()), 51 | ), 52 | grpc.WithStreamInterceptor( 53 | grpc_middleware.ChainStreamClient(grpcMetrics.StreamClientInterceptor()), 54 | ), 55 | grpc.WithTransportCredentials(insecure.NewCredentials()), 56 | grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`), 57 | } 58 | 59 | if reg != nil { 60 | reg.MustRegister(grpcMetrics) 61 | } 62 | 63 | return &Client{dialOpts: dialOpts} 64 | } 65 | 66 | // Dial connects the client to gubernator. 67 | func (c *Client) Dial(ctx context.Context, address string) error { 68 | address = fmt.Sprintf("dns:///%s", address) 69 | conn, err := grpc.DialContext(ctx, address, c.dialOpts...) // nolint: staticcheck 70 | if err != nil { 71 | return fmt.Errorf("failed to dial gubernator with %q: %v", address, err) 72 | } 73 | 74 | c.client = gubernator.NewV1Client(conn) 75 | 76 | return nil 77 | } 78 | 79 | // GetRateLimits gets the rate limits corresponding to a request. 80 | // Note: Dial must be called before calling this method, otherwise the client will panic. 81 | func (c *Client) GetRateLimits(ctx context.Context, req *Request) (remaining, resetTime int64, err error) { 82 | resp, err := c.client.GetRateLimits(ctx, &gubernator.GetRateLimitsReq{ 83 | Requests: []*gubernator.RateLimitReq{{ 84 | Name: req.name, 85 | UniqueKey: req.Key, 86 | Hits: 1, 87 | Limit: req.Limit, 88 | Duration: req.Duration, 89 | Algorithm: gubernator.Algorithm_LEAKY_BUCKET, 90 | Behavior: gubernator.Behavior_GLOBAL, 91 | }}, 92 | }) 93 | if err != nil { 94 | return 0, 0, err 95 | } 96 | 97 | response := resp.Responses[0] 98 | if response.Status == gubernator.Status_OVER_LIMIT { 99 | return 0, 0, ErrOverLimit 100 | } 101 | 102 | return response.GetRemaining(), response.GetResetTime(), nil 103 | } 104 | -------------------------------------------------------------------------------- /ratelimit/gcra_rate_limit.lua: -------------------------------------------------------------------------------- 1 | -- this script has side-effects, so it requires replicate commands mode 2 | redis.replicate_commands() 3 | 4 | local rate_limit_key = KEYS[1] -- The key to the rate limit bucket. 5 | local now = ARGV[1] -- Current time (Unix time in milliseconds). 6 | local burst = ARGV[2] -- This represents the total capacity of the bucket. 7 | local rate = ARGV[3] -- This represents the amount that leaks from the bucket. 8 | local period = ARGV[4] -- This represents how often the "rate" leaks from the bucket (in milliseconds). 9 | local cost = ARGV[5] -- This represents the cost of the request. Often 1 is used per request. 10 | -- It allows some requests to be assigned a higher cost. 11 | 12 | local emission_interval = period / rate 13 | local increment = emission_interval * cost 14 | local burst_offset = emission_interval * burst 15 | 16 | local tat = redis.call("GET", rate_limit_key) 17 | 18 | if not tat then 19 | tat = now 20 | else 21 | tat = tonumber(tat) 22 | end 23 | tat = math.max(tat, now) 24 | 25 | local new_tat = tat + increment 26 | local allow_at = new_tat - burst_offset 27 | local diff = now - allow_at 28 | 29 | local limited 30 | local retry_in 31 | local reset_in 32 | 33 | local remaining = math.floor(diff / emission_interval) -- poor man's round 34 | 35 | if remaining < 0 then 36 | limited = 1 37 | -- calculate how many tokens there actually are, since 38 | -- remaining is how many there would have been if we had been able to limit 39 | -- and we did not limit 40 | remaining = math.floor((now - (tat - burst_offset)) / emission_interval) 41 | reset_in = math.ceil(tat - now) 42 | retry_in = math.ceil(diff * -1) 43 | elseif remaining == 0 and increment <= 0 then 44 | -- request with cost of 0 45 | -- cost of 0 with remaining 0 is still limited 46 | limited = 1 47 | remaining = 0 48 | reset_in = math.ceil(tat - now) 49 | retry_in = 0 -- retry in is meaningless when cost is 0 50 | else 51 | limited = 0 52 | reset_in = math.ceil(new_tat - now) 53 | retry_in = 0 54 | if increment > 0 then 55 | redis.call("SET", rate_limit_key, new_tat, "PX", reset_in) 56 | end 57 | end 58 | 59 | -- return values (in order): 60 | -- limited = integer-encoded boolean, 1 if limited, 0 if not 61 | -- remaining = number of tokens remaining 62 | -- retry_in = milliseconds until the next request will be allowed 63 | -- reset_in = milliseconds until the rate limit window resets 64 | -- diff = milliseconds since the last request 65 | -- emission_interval = milliseconds between token emissions 66 | return {limited, remaining, retry_in, reset_in, tostring(diff), tostring(emission_interval)} 67 | -------------------------------------------------------------------------------- /ratelimit/gubernator/README.md: -------------------------------------------------------------------------------- 1 | # [Gubernator](https://github.com/mailgun/gubernator) 2 | 3 | [Gubernator](https://github.com/mailgun/gubernator) is a distributed, high performance, cloud native and stateless rate limiting service. 4 | 5 | We generate gRPC client code (`gubernator.pb.go`) using `gobernator.proto` which is a file directly download from the project repository. 6 | You can find the 3rd party dependencies for proto generation under `proto` directory. 7 | To update or re-generate please use `make proto`. 8 | 9 | -------------------------------------------------------------------------------- /ratelimit/gubernator/proto/google/api/annotations.proto: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package google.api; 18 | 19 | import "google/api/http.proto"; 20 | import "google/protobuf/descriptor.proto"; 21 | 22 | option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; 23 | option java_multiple_files = true; 24 | option java_outer_classname = "AnnotationsProto"; 25 | option java_package = "com.google.api"; 26 | option objc_class_prefix = "GAPI"; 27 | 28 | extend google.protobuf.MethodOptions { 29 | // See `HttpRule`. 30 | HttpRule http = 72295728; 31 | } 32 | -------------------------------------------------------------------------------- /ratelimit/gubernator/proto/google/api/httpbody.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | 16 | syntax = "proto3"; 17 | 18 | package google.api; 19 | 20 | import "google/protobuf/any.proto"; 21 | 22 | option cc_enable_arenas = true; 23 | option go_package = "google.golang.org/genproto/googleapis/api/httpbody;httpbody"; 24 | option java_multiple_files = true; 25 | option java_outer_classname = "HttpBodyProto"; 26 | option java_package = "com.google.api"; 27 | option objc_class_prefix = "GAPI"; 28 | 29 | // Message that represents an arbitrary HTTP body. It should only be used for 30 | // payload formats that can't be represented as JSON, such as raw binary or 31 | // an HTML page. 32 | // 33 | // 34 | // This message can be used both in streaming and non-streaming API methods in 35 | // the request as well as the response. 36 | // 37 | // It can be used as a top-level request field, which is convenient if one 38 | // wants to extract parameters from either the URL or HTTP template into the 39 | // request fields and also want access to the raw HTTP body. 40 | // 41 | // Example: 42 | // 43 | // message GetResourceRequest { 44 | // // A unique request id. 45 | // string request_id = 1; 46 | // 47 | // // The raw HTTP body is bound to this field. 48 | // google.api.HttpBody http_body = 2; 49 | // } 50 | // 51 | // service ResourceService { 52 | // rpc GetResource(GetResourceRequest) returns (google.api.HttpBody); 53 | // rpc UpdateResource(google.api.HttpBody) returns 54 | // (google.protobuf.Empty); 55 | // } 56 | // 57 | // Example with streaming methods: 58 | // 59 | // service CaldavService { 60 | // rpc GetCalendar(stream google.api.HttpBody) 61 | // returns (stream google.api.HttpBody); 62 | // rpc UpdateCalendar(stream google.api.HttpBody) 63 | // returns (stream google.api.HttpBody); 64 | // } 65 | // 66 | // Use of this type only changes how the request and response bodies are 67 | // handled, all other features will continue to work unchanged. 68 | message HttpBody { 69 | // The HTTP Content-Type header value specifying the content type of the body. 70 | string content_type = 1; 71 | 72 | // The HTTP request/response body as raw binary. 73 | bytes data = 2; 74 | 75 | // Application specific response metadata. Must be set in the first response 76 | // for streaming APIs. 77 | repeated google.protobuf.Any extensions = 3; 78 | } -------------------------------------------------------------------------------- /ratelimit/gubernator/proto/google/rpc/status.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package google.rpc; 18 | 19 | import "google/protobuf/any.proto"; 20 | 21 | option go_package = "google.golang.org/genproto/googleapis/rpc/status;status"; 22 | option java_multiple_files = true; 23 | option java_outer_classname = "StatusProto"; 24 | option java_package = "com.google.rpc"; 25 | option objc_class_prefix = "RPC"; 26 | 27 | 28 | // The `Status` type defines a logical error model that is suitable for different 29 | // programming environments, including REST APIs and RPC APIs. It is used by 30 | // [gRPC](https://github.com/grpc). The error model is designed to be: 31 | // 32 | // - Simple to use and understand for most users 33 | // - Flexible enough to meet unexpected needs 34 | // 35 | // # Overview 36 | // 37 | // The `Status` message contains three pieces of data: error code, error message, 38 | // and error details. The error code should be an enum value of 39 | // [google.rpc.Code][google.rpc.Code], but it may accept additional error codes if needed. The 40 | // error message should be a developer-facing English message that helps 41 | // developers *understand* and *resolve* the error. If a localized user-facing 42 | // error message is needed, put the localized message in the error details or 43 | // localize it in the client. The optional error details may contain arbitrary 44 | // information about the error. There is a predefined set of error detail types 45 | // in the package `google.rpc` that can be used for common error conditions. 46 | // 47 | // # Language mapping 48 | // 49 | // The `Status` message is the logical representation of the error model, but it 50 | // is not necessarily the actual wire format. When the `Status` message is 51 | // exposed in different client libraries and different wire protocols, it can be 52 | // mapped differently. For example, it will likely be mapped to some exceptions 53 | // in Java, but more likely mapped to some error codes in C. 54 | // 55 | // # Other uses 56 | // 57 | // The error model and the `Status` message can be used in a variety of 58 | // environments, either with or without APIs, to provide a 59 | // consistent developer experience across different environments. 60 | // 61 | // Example uses of this error model include: 62 | // 63 | // - Partial errors. If a service needs to return partial errors to the client, 64 | // it may embed the `Status` in the normal response to indicate the partial 65 | // errors. 66 | // 67 | // - Workflow errors. A typical workflow has multiple steps. Each step may 68 | // have a `Status` message for error reporting. 69 | // 70 | // - Batch operations. If a client uses batch request and batch response, the 71 | // `Status` message should be used directly inside batch response, one for 72 | // each error sub-response. 73 | // 74 | // - Asynchronous operations. If an API call embeds asynchronous operation 75 | // results in its response, the status of those operations should be 76 | // represented directly using the `Status` message. 77 | // 78 | // - Logging. If some API errors are stored in logs, the message `Status` could 79 | // be used directly after any stripping needed for security/privacy reasons. 80 | message Status { 81 | // The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code]. 82 | int32 code = 1; 83 | 84 | // A developer-facing error message, which should be in English. Any 85 | // user-facing error message should be localized and sent in the 86 | // [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client. 87 | string message = 2; 88 | 89 | // A list of messages that carry the error details. There is a common set of 90 | // message types for APIs to use. 91 | repeated google.protobuf.Any details = 3; 92 | } 93 | -------------------------------------------------------------------------------- /ratelimit/redis.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/redis/rueidis" 10 | ) 11 | 12 | //go:embed gcra_rate_limit.lua 13 | var gcraRateLimitScript string 14 | 15 | // RedisRateLimiter is a type that represents a rate limiter that uses Redis as its backend. 16 | // The rate limiting is a leaky bucket implementation using the generic cell rate algorithm. 17 | // See https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm for details on how this algorithm works. 18 | type RedisRateLimiter struct { 19 | client rueidis.Client 20 | } 21 | 22 | // Ensure RedisRateLimiter implements the SharedRateLimiter interface. 23 | var _ SharedRateLimiter = (*RedisRateLimiter)(nil) 24 | 25 | // NewRedisRateLimiter creates a new instance of RedisRateLimiter. 26 | func NewRedisRateLimiter(addresses []string) (*RedisRateLimiter, error) { 27 | client, err := rueidis.NewClient(rueidis.ClientOption{InitAddress: addresses}) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &RedisRateLimiter{client: client}, nil 32 | } 33 | 34 | // GetRateLimits retrieves the rate limits for a given request using a Redis Rate Limiter. 35 | // It returns the amount of remaining requests, the reset time in milliseconds, and any error that occurred. 36 | func (r *RedisRateLimiter) GetRateLimits(ctx context.Context, req *Request) (remaining, resetTime int64, err error) { 37 | inspectScript := rueidis.NewLuaScript(gcraRateLimitScript) 38 | rateLimitParameters := []string{ 39 | strconv.FormatInt(time.Now().UnixMilli(), 10), // now 40 | strconv.FormatInt(req.Limit, 10), // burst 41 | strconv.FormatInt(req.Limit, 10), // rate 42 | strconv.FormatInt(req.Duration, 10), // period 43 | "1", // cost 44 | } 45 | result := inspectScript.Exec(ctx, r.client, []string{req.Key}, rateLimitParameters) 46 | limited, remaining, resetIn, err := r.parseRateLimitResult(&result) 47 | if err != nil { 48 | return 0, 0, err 49 | } 50 | resetTime = time.Now().Add(time.Duration(resetIn) * time.Millisecond).UnixMilli() 51 | if limited { 52 | return remaining, resetTime, ErrOverLimit 53 | } 54 | return remaining, resetTime, nil 55 | } 56 | 57 | // parseRateLimitResult parses the result of a rate limit check from Redis. 58 | // It takes a RedisResult as input and returns the parsed rate limit values: whether the request is limited, 59 | // the number of remaining requests, the reset time in milliseconds, and any error that occurred during parsing. 60 | func (r *RedisRateLimiter) parseRateLimitResult(result *rueidis.RedisResult) (limited bool, remaining, resetIn int64, err error) { 61 | values, err := result.ToArray() 62 | if err != nil { 63 | return false, 0, 0, err 64 | } 65 | 66 | limited, err = values[0].AsBool() 67 | if err != nil { 68 | return false, 0, 0, err 69 | } 70 | 71 | remaining, err = values[1].AsInt64() 72 | if err != nil { 73 | return false, 0, 0, err 74 | } 75 | 76 | resetIn, err = values[3].AsInt64() 77 | if err != nil { 78 | return false, 0, 0, err 79 | } 80 | 81 | return limited, remaining, resetIn, nil 82 | } 83 | -------------------------------------------------------------------------------- /rules/custom_types.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | func (rg *RuleGroup) UnmarshalJSON(data []byte) error { 8 | raw := struct { 9 | Interval string `json:"interval"` 10 | Name string `json:"name"` 11 | Rules []json.RawMessage `json:"rules"` 12 | }{} 13 | if err := json.Unmarshal(data, &raw); err != nil { 14 | return err 15 | } 16 | 17 | rg.Interval = raw.Interval 18 | rg.Name = raw.Name 19 | rules := make([]interface{}, 0, len(raw.Rules)) 20 | 21 | for i := range raw.Rules { 22 | rawRule := make(map[string]json.RawMessage) 23 | if err := json.Unmarshal(raw.Rules[i], &rawRule); err != nil { 24 | return err 25 | } 26 | 27 | switch _, ok := rawRule["alert"]; ok { 28 | case true: 29 | var ar AlertingRule 30 | if err := json.Unmarshal(raw.Rules[i], &ar); err != nil { 31 | return err 32 | } 33 | 34 | rules = append(rules, ar) 35 | case false: 36 | var rr RecordingRule 37 | if err := json.Unmarshal(raw.Rules[i], &rr); err != nil { 38 | return err 39 | } 40 | 41 | rules = append(rules, rr) 42 | } 43 | } 44 | 45 | if len(rules) != 0 { 46 | rg.Rules = rules 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /rules/custom_types_test.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ghodss/yaml" 7 | ) 8 | 9 | func TestRuleGroupUnmarshalJSON(t *testing.T) { 10 | for _, testCase := range []struct { 11 | name string 12 | raw []byte 13 | out RuleGroup 14 | err bool 15 | }{ 16 | { 17 | name: "almost empty", 18 | raw: []byte("{}"), 19 | }, 20 | { 21 | name: "one recording rule", 22 | raw: []byte(` 23 | name: foo 24 | interval: 5s 25 | rules: 26 | - record: bar 27 | expr: vector(1)`), 28 | out: RuleGroup{ 29 | Name: "foo", 30 | Interval: "5s", 31 | Rules: []interface{}{ 32 | RecordingRule{ 33 | Record: "bar", 34 | Expr: "vector(1)", 35 | Labels: RecordingRule_Labels{AdditionalProperties: make(map[string]string)}, 36 | }, 37 | }, 38 | }, 39 | }, 40 | { 41 | name: "one alerting rule", 42 | raw: []byte(` 43 | name: foo 44 | interval: 5s 45 | rules: 46 | - alert: HighRequestLatency 47 | expr: job:request_latency_seconds:mean5m{job="myjob"} > 0.5 48 | for: 10m`), 49 | out: RuleGroup{ 50 | Name: "foo", 51 | Interval: "5s", 52 | Rules: []interface{}{ 53 | AlertingRule{ 54 | Alert: "HighRequestLatency", 55 | Expr: `job:request_latency_seconds:mean5m{job="myjob"} > 0.5`, 56 | For: "10m", 57 | Annotations: AlertingRule_Annotations{AdditionalProperties: make(map[string]string)}, 58 | Labels: AlertingRule_Labels{AdditionalProperties: make(map[string]string)}, 59 | }, 60 | }, 61 | }, 62 | }, 63 | { 64 | name: "one of each", 65 | raw: []byte(` 66 | name: foo 67 | interval: 5s 68 | rules: 69 | - record: bar 70 | expr: vector(1) 71 | - alert: HighRequestLatency 72 | expr: job:request_latency_seconds:mean5m{job="myjob"} > 0.5 73 | for: 10m`), 74 | out: RuleGroup{ 75 | Name: "foo", 76 | Interval: "5s", 77 | Rules: []interface{}{ 78 | RecordingRule{ 79 | Record: "bar", 80 | Expr: "vector(1)", 81 | Labels: RecordingRule_Labels{AdditionalProperties: make(map[string]string)}, 82 | }, 83 | AlertingRule{ 84 | Alert: "HighRequestLatency", 85 | Expr: `job:request_latency_seconds:mean5m{job="myjob"} > 0.5`, 86 | For: "10m", 87 | Annotations: AlertingRule_Annotations{AdditionalProperties: make(map[string]string)}, 88 | Labels: AlertingRule_Labels{AdditionalProperties: make(map[string]string)}, 89 | }, 90 | }, 91 | }, 92 | }, 93 | } { 94 | tc := testCase 95 | t.Run(tc.name, func(t *testing.T) { 96 | var out RuleGroup 97 | if err := yaml.Unmarshal(tc.raw, &out); err != nil { 98 | if !tc.err { 99 | t.Fatalf("got unexpected error %v", err) 100 | } 101 | } else { 102 | if tc.err { 103 | t.Fatal("expected error") 104 | } 105 | if !ruleGroupsEqual(out, tc.out) { 106 | t.Errorf("expected %v; got %v", tc.out, out) 107 | } 108 | } 109 | }) 110 | } 111 | } 112 | 113 | func ruleGroupsEqual(a, b RuleGroup) bool { 114 | if a.Interval != b.Interval { 115 | return false 116 | } 117 | 118 | if a.Name != b.Name { 119 | return false 120 | } 121 | 122 | if (a.Rules != nil) != (b.Rules != nil) { 123 | return false 124 | } 125 | 126 | if len(a.Rules) != len(b.Rules) { 127 | return false 128 | } 129 | 130 | for i := range a.Rules { 131 | ara, aok := a.Rules[i].(AlertingRule) 132 | bra, bok := b.Rules[i].(AlertingRule) 133 | 134 | if aok != bok { 135 | return false 136 | } 137 | 138 | if aok { 139 | if ara.Alert != bra.Alert { 140 | return false 141 | } 142 | 143 | if ara.Expr != bra.Expr { 144 | return false 145 | } 146 | 147 | if ara.For != bra.For { 148 | return false 149 | } 150 | 151 | if mapsEqual(ara.Annotations.AdditionalProperties, bra.Annotations.AdditionalProperties) { 152 | return false 153 | } 154 | 155 | if mapsEqual(ara.Labels.AdditionalProperties, bra.Labels.AdditionalProperties) { 156 | return false 157 | } 158 | 159 | continue 160 | } 161 | 162 | arr, aok := a.Rules[i].(RecordingRule) 163 | brr, bok := b.Rules[i].(RecordingRule) 164 | 165 | if aok != bok { 166 | return false 167 | } 168 | 169 | if aok { 170 | if arr.Expr != brr.Expr { 171 | return false 172 | } 173 | 174 | if arr.Record != brr.Record { 175 | return false 176 | } 177 | 178 | if mapsEqual(arr.Labels.AdditionalProperties, brr.Labels.AdditionalProperties) { 179 | return false 180 | } 181 | 182 | continue 183 | } 184 | } 185 | 186 | return true 187 | } 188 | 189 | func mapsEqual(a, b map[string]string) bool { 190 | if a == nil && b == nil { 191 | return true 192 | } 193 | 194 | if (a != nil) != (b != nil) { 195 | return false 196 | } 197 | 198 | if len(a) != len(b) { 199 | return false 200 | } 201 | 202 | for k := range a { 203 | if a[k] != b[k] { 204 | return false 205 | } 206 | } 207 | 208 | return true 209 | } 210 | -------------------------------------------------------------------------------- /rules/spec.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | description: The Rules Backend API 4 | version: "0.0.2" 5 | title: Rules Backend API 6 | license: 7 | name: Apache 2.0 8 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html' 9 | tags: 10 | - name: rulesv1 11 | description: Calls related to rules 12 | paths: 13 | /api/v1/rules/{tenant}: 14 | parameters: 15 | - in: path 16 | name: tenant 17 | description: name of the tenant 18 | required: true 19 | schema: 20 | type: string 21 | get: 22 | tags: 23 | - rulesv1 24 | summary: lists all rules for a tenant 25 | operationId: listRules 26 | description: | 27 | You can list all rules for a tenant 28 | responses: 29 | '200': 30 | description: all rules for that tenant 31 | content: 32 | application/yaml: 33 | schema: 34 | $ref: '#/components/schemas/Rules' 35 | '404': 36 | description: invalid tenant 37 | put: 38 | tags: 39 | - rulesv1 40 | summary: set/overwrite the rules for the tenant 41 | operationId: setRules 42 | description: Set rules for the tenant 43 | responses: 44 | '201': 45 | description: rule file created 46 | '400': 47 | description: 'invalid rules object' 48 | requestBody: 49 | content: 50 | application/yaml: 51 | schema: 52 | $ref: '#/components/schemas/Rules' 53 | description: Rules to set 54 | /api/v1/rules: 55 | get: 56 | tags: 57 | - rulesv1 58 | summary: lists all rules for all tenants 59 | operationId: listAllRules 60 | description: | 61 | You can list all rules for all tenants 62 | responses: 63 | '200': 64 | description: rules for all tenants 65 | content: 66 | application/yaml: 67 | schema: 68 | $ref: '#/components/schemas/Rules' 69 | components: 70 | schemas: 71 | Rules: 72 | type: object 73 | required: 74 | - groups 75 | properties: 76 | groups: 77 | type: array 78 | items: 79 | $ref: '#/components/schemas/RuleGroup' 80 | RuleGroup: 81 | type: object 82 | required: 83 | - interval 84 | - name 85 | - rules 86 | properties: 87 | interval: 88 | type: string 89 | example: '1m' 90 | name: 91 | type: string 92 | example: 'telemeter.rules' 93 | rules: 94 | type: array 95 | items: 96 | oneOf: 97 | - $ref: '#/components/schemas/RecordingRule' 98 | - $ref: '#/components/schemas/AlertingRule' 99 | RecordingRule: 100 | type: object 101 | required: 102 | - expr 103 | - record 104 | - labels 105 | properties: 106 | expr: 107 | type: string 108 | example: 'count by (name) (cluster{condition="halted"} == 1)' 109 | record: 110 | type: string 111 | example: 'count:cluster_halted' 112 | labels: 113 | type: object 114 | additionalProperties: 115 | type: string 116 | AlertingRule: 117 | type: object 118 | required: 119 | - alert 120 | - expr 121 | - for 122 | - labels 123 | - annotations 124 | properties: 125 | alert: 126 | type: string 127 | example: 'HighRequestLatency' 128 | expr: 129 | type: string 130 | example: 'job:request_latency_seconds:mean5m{job="myjob"} > 0.5' 131 | for: 132 | type: string 133 | example: '10m' 134 | labels: 135 | type: object 136 | additionalProperties: 137 | type: string 138 | annotations: 139 | type: object 140 | additionalProperties: 141 | type: string 142 | -------------------------------------------------------------------------------- /scripts/generate_proto.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | PROTOC_BIN=${PROTOC_BIN:-protoc} 6 | DIRS=${DIRS:-"ratelimit/gubernator"} 7 | PROTOC_INCLUDE=${PROTOC_INCLUDE:-"tmp/protoc/include"} 8 | 9 | if ! [[ "scripts/generate_proto.sh" =~ $0 ]]; then 10 | echo "must be run from repository root" 11 | exit 255 12 | fi 13 | 14 | if ! [[ $(${PROTOC_BIN} --version) == *"3.13.0"* ]]; then 15 | echo "could not find protoc 3.13.0, is it installed + in PATH?" 16 | exit 255 17 | fi 18 | 19 | echo "generating code" 20 | for dir in ${DIRS}; do 21 | ${PROTOC_BIN} --go_out=plugins=grpc:. \ 22 | -I=. \ 23 | -I="${dir}"/proto \ 24 | -I="${PROTOC_INCLUDE}" \ 25 | "${dir}"/*.proto 26 | done 27 | 28 | -------------------------------------------------------------------------------- /scripts/install_protoc.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | PROTOC_VERSION=${PROTOC_VERSION:-3.13.0} 6 | TMP_DIR=${TMP_DIR:-./tmp} 7 | BIN_DIR=${BIN_DIR:-./tmp/bin} 8 | PROTOC_DOWNLOAD_URL="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}" 9 | 10 | OS=$(go env GOOS) 11 | ARCH=$(go env GOARCH) 12 | PLATFORM="${OS}/${ARCH}" 13 | 14 | is_supported_platform() { 15 | platform=$1 16 | found=1 17 | case "$platform" in 18 | darwin/amd64) found=0 ;; 19 | darwin/i386) found=0 ;; 20 | linux/amd64) found=0 ;; 21 | linux/i386) found=0 ;; 22 | linux/arm64) found=0 ;; 23 | esac 24 | return $found 25 | } 26 | 27 | adjust_os() { 28 | case ${OS} in 29 | darwin) OS=osx ;; 30 | esac 31 | true 32 | } 33 | 34 | adjust_arch() { 35 | case ${ARCH} in 36 | amd64) ARCH=x86_64 ;; 37 | i386) ARCH=x86_32 ;; 38 | arm64) ARCH=aarch_64 ;; 39 | esac 40 | true 41 | } 42 | 43 | mkdir -p "${TMP_DIR}" 44 | 45 | is_supported_platform "$PLATFORM" 46 | if [[ $? -eq 1 ]]; then 47 | echo "platform $PLATFORM is not supported. See https://github.com/protocolbuffers/protobuf/releases for details." 48 | exit 1 49 | fi 50 | 51 | adjust_os 52 | 53 | adjust_arch 54 | 55 | PACKAGE="protoc-${PROTOC_VERSION}-${OS}-${ARCH}.zip" 56 | PACKAGE_DOWNLOAD_URL="${PROTOC_DOWNLOAD_URL}/${PACKAGE}" 57 | curl -LSs "${PACKAGE_DOWNLOAD_URL}" -o "${TMP_DIR}/${PACKAGE}" 58 | unzip -qq "${TMP_DIR}/${PACKAGE}" -d "${TMP_DIR}/protoc/" 59 | mv -f "${TMP_DIR}/protoc/bin/protoc" "${BIN_DIR}" 60 | -------------------------------------------------------------------------------- /server/http_instrumentation_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "math/rand" 5 | "net/http" 6 | "net/http/httptest" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/prometheus/client_golang/prometheus" 12 | ) 13 | 14 | func TestInstrumentedHandlerFactory_ConcurrentAccess(t *testing.T) { 15 | r := prometheus.NewRegistry() 16 | hardcodedLabels := []string{"group", "handler"} 17 | extraLabels := prometheus.Labels{"group": "test", "handler": "concurrency"} 18 | handler := http.HandlerFunc(func(wr http.ResponseWriter, r *http.Request) { 19 | time.Sleep(time.Duration(rand.Int63n(100) * int64(time.Millisecond))) 20 | }) 21 | numRequests := 1000 22 | 23 | f := NewInstrumentedHandlerFactory(r, hardcodedLabels) 24 | h := f.NewHandler(extraLabels, handler) 25 | 26 | wg := &sync.WaitGroup{} 27 | wg.Add(numRequests) 28 | for i := 0; i < numRequests; i++ { 29 | go func() { 30 | defer wg.Done() 31 | 32 | wr := httptest.NewRecorder() 33 | r := httptest.NewRequest(http.MethodGet, "/", nil) 34 | time.Sleep(time.Duration(rand.Int63n(100) * int64(time.Millisecond))) 35 | h(wr, r) 36 | }() 37 | } 38 | 39 | wg.Wait() 40 | } 41 | -------------------------------------------------------------------------------- /server/instrumentation.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/go-chi/chi/middleware" 8 | "github.com/go-kit/log" 9 | "github.com/go-kit/log/level" 10 | ) 11 | 12 | // Logger returns a middleware to log HTTP requests. 13 | func Logger(logger log.Logger) func(next http.Handler) http.Handler { 14 | return func(next http.Handler) http.Handler { 15 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | start := time.Now() 17 | 18 | ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 19 | next.ServeHTTP(ww, r) 20 | 21 | keyvals := []interface{}{ 22 | "request", middleware.GetReqID(r.Context()), 23 | "proto", r.Proto, 24 | "method", r.Method, 25 | "status", ww.Status(), 26 | "content", r.Header.Get("Content-Type"), 27 | "path", r.URL.Path, 28 | "duration", time.Since(start), 29 | "bytes", ww.BytesWritten(), 30 | } 31 | 32 | if ww.Status()/100 == 5 { 33 | level.Warn(logger).Log(keyvals...) 34 | return 35 | } 36 | level.Debug(logger).Log(keyvals...) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/paths.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "path" 7 | "strings" 8 | 9 | "github.com/observatorium/api/authentication" 10 | "github.com/observatorium/api/httperr" 11 | "github.com/observatorium/api/proxy" 12 | 13 | "github.com/go-chi/chi" 14 | "github.com/go-kit/log" 15 | "github.com/go-kit/log/level" 16 | ) 17 | 18 | // PathsHandlerFunc lists all paths available from the provided routes. 19 | func PathsHandlerFunc(logger log.Logger, routes []chi.Route) http.HandlerFunc { 20 | paths := make([]string, 0, len(routes)) 21 | for _, r := range routes { 22 | paths = append(paths, r.Pattern) 23 | } 24 | 25 | pathsStruct := struct { 26 | Paths []string `json:"paths"` 27 | }{ 28 | Paths: paths, 29 | } 30 | 31 | return func(w http.ResponseWriter, r *http.Request) { 32 | externalPathJSON, err := json.MarshalIndent(pathsStruct, "", " ") 33 | if err != nil { 34 | level.Error(logger).Log("msg", "failed to marshal paths input to JSON", "err", err.Error()) 35 | w.WriteHeader(http.StatusInternalServerError) 36 | return 37 | } 38 | 39 | w.Header().Add("Content-Type", "application/json") 40 | if _, err := w.Write(externalPathJSON); err != nil { 41 | level.Error(logger).Log("msg", "could not write external paths", "err", err.Error()) 42 | } 43 | } 44 | } 45 | 46 | func StripTenantPrefix(prefix string) func(http.Handler) http.Handler { 47 | return func(next http.Handler) http.Handler { 48 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 | tenant, ok := authentication.GetTenant(r.Context()) 50 | if !ok { 51 | httperr.PrometheusAPIError(w, "tenant not found", http.StatusInternalServerError) 52 | return 53 | } 54 | 55 | tenantPrefix := path.Join("/", prefix, tenant) 56 | http.StripPrefix(tenantPrefix, proxy.WithPrefix(tenantPrefix, next)).ServeHTTP(w, r) 57 | }) 58 | } 59 | } 60 | 61 | func StripTenantPrefixWithSubRoute(prefix, route string) func(http.Handler) http.Handler { 62 | return func(next http.Handler) http.Handler { 63 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 64 | tenant, ok := authentication.GetTenant(r.Context()) 65 | if !ok { 66 | httperr.PrometheusAPIError(w, "tenant not found", http.StatusInternalServerError) 67 | return 68 | } 69 | 70 | route = strings.TrimPrefix(route, "/") 71 | tenantPrefix := path.Join("/", prefix, tenant, route) 72 | http.StripPrefix(tenantPrefix, proxy.WithPrefix(tenantPrefix, next)).ServeHTTP(w, r) 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/config/hashrings.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "hashring": "test", 4 | "tenants": [ 5 | "1610b0c3-c509-4592-a256-a1871353dbfa", 6 | "177ef09c-04e1-46c5-86f7-dc3250bfe869" 7 | ], 8 | "endpoints": [ 9 | "0.0.0.0:10901" 10 | ] 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /test/config/observatorium.rego: -------------------------------------------------------------------------------- 1 | package observatorium 2 | 3 | import input 4 | import data.roles 5 | import data.roleBindings 6 | 7 | default allow = false 8 | 9 | allow { 10 | some roleNames 11 | roleNames = roleBindings[matched_role_binding[_]].roles 12 | roles[i].name == roleNames[_] 13 | roles[i].resources[_] = input.resource 14 | roles[i].permissions[_] = input.permission 15 | roles[i].tenants[_] = input.tenant 16 | } 17 | 18 | matched_role_binding[i] { 19 | roleBindings[i].subjects[_] == {"name": input.subject, "kind": "user"} 20 | } 21 | 22 | matched_role_binding[i] { 23 | roleBindings[i].subjects[_] == {"name": input.groups[_], "kind": "group"} 24 | } 25 | -------------------------------------------------------------------------------- /test/config/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_configs: 3 | - job_name: 'observatorium' 4 | scrape_interval: 1s 5 | static_configs: 6 | - targets: ['localhost:8080'] 7 | -------------------------------------------------------------------------------- /test/config/rbac.yaml: -------------------------------------------------------------------------------- 1 | roles: 2 | - name: read-write-oidc 3 | resources: 4 | - metrics 5 | - logs 6 | - traces 7 | tenants: 8 | - test-oidc 9 | permissions: 10 | - read 11 | - write 12 | - name: read-write-another-tenant 13 | resources: 14 | - metrics 15 | - logs 16 | - traces 17 | tenants: 18 | - another-tenant 19 | permissions: 20 | - read 21 | - write 22 | - name: read-attacker 23 | resources: 24 | - metrics 25 | tenants: 26 | - test-attacker 27 | permissions: 28 | - read 29 | - name: read-write-mtls 30 | resources: 31 | - metrics 32 | - logs 33 | - traces 34 | tenants: 35 | - test-mtls 36 | permissions: 37 | - read 38 | - write 39 | roleBindings: 40 | - name: test-oidc 41 | roles: 42 | - read-write-oidc 43 | - read-attacker 44 | subjects: 45 | - name: admin@example.com 46 | kind: user 47 | - name: another-tenant 48 | roles: 49 | - read-write-another-tenant 50 | subjects: 51 | - name: admin@example.com 52 | kind: user 53 | - name: test-mtls 54 | roles: 55 | - read-write-mtls 56 | subjects: 57 | - name: test 58 | kind: group 59 | -------------------------------------------------------------------------------- /test/dex/static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/test/dex/static/.gitkeep -------------------------------------------------------------------------------- /test/dex/templates/approval.html: -------------------------------------------------------------------------------- 1 | 2 |

Grant Access

3 |
4 |
5 |
{{ .Client }} would like to:
6 |
    7 | {{ range $scope := .Scopes }} 8 |
  • {{ $scope }}
  • 9 | {{ end }} 10 |
11 |
12 |
13 |
14 | 15 | 16 | 19 |
20 |
21 | 22 | 23 | 26 |
27 | 28 | -------------------------------------------------------------------------------- /test/dex/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/dex/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/dex/templates/oob.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/dex/templates/password.html: -------------------------------------------------------------------------------- 1 | 2 |

Log in to Your Account

3 |
4 | 5 | 6 | 7 | 8 | {{ if .Invalid }} 9 |
10 | Invalid {{ .UsernamePrompt }} and password. 11 |
12 | {{ end }} 13 | 14 |
15 | 16 | -------------------------------------------------------------------------------- /test/dex/themes/coreos/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/api/ebbaf26c1568e87e44ab9e14f326482478e98322/test/dex/themes/coreos/.gitkeep -------------------------------------------------------------------------------- /test/e2e/helpers.go: -------------------------------------------------------------------------------- 1 | //go:build integration || interactive 2 | 3 | package e2e 4 | 5 | import ( 6 | "crypto/tls" 7 | "crypto/x509" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "strings" 17 | "testing" 18 | 19 | "github.com/efficientgo/core/testutil" 20 | "github.com/efficientgo/e2e" 21 | "github.com/observatorium/api/test/testtls" 22 | ) 23 | 24 | // Generates certificates and copies static configuration to the shared directory. 25 | func prepareConfigsAndCerts(t *testing.T, tt testType, e e2e.Environment) { 26 | testutil.Ok( 27 | t, 28 | testtls.GenerateCerts( 29 | filepath.Join(e.SharedDir(), certsSharedDir), 30 | getContainerName(t, tt, "observatorium-api"), 31 | []string{getContainerName(t, tt, "observatorium-api"), "127.0.0.1"}, 32 | getContainerName(t, tt, "dex"), 33 | []string{getContainerName(t, tt, "dex"), "127.0.0.1"}, 34 | ), 35 | ) 36 | 37 | testutil.Ok(t, exec.Command("cp", "-r", "../config", filepath.Join(e.SharedDir(), configSharedDir)).Run()) 38 | } 39 | 40 | // obtainToken obtains a bearer token needed for communication with the API. 41 | func obtainToken(endpoint string, tlsConf *tls.Config) (string, error) { 42 | type token struct { 43 | IDToken string `json:"id_token"` 44 | } 45 | 46 | data := url.Values{} 47 | data.Add("grant_type", "password") 48 | data.Add("username", "admin@example.com") 49 | data.Add("password", "password") 50 | data.Add("client_id", "test") 51 | data.Add("client_secret", "ZXhhbXBsZS1hcHAtc2VjcmV0") 52 | data.Add("scope", "openid email") 53 | 54 | r, err := http.NewRequest(http.MethodPost, "https://"+endpoint+"/dex/token", strings.NewReader(data.Encode())) 55 | if err != nil { 56 | return "", fmt.Errorf("cannot create new request: %v\n", err) 57 | } 58 | r.Header.Add("Content-Type", "application/x-www-form-urlencoded") 59 | 60 | c := &http.Client{ 61 | Transport: &http.Transport{ 62 | TLSClientConfig: tlsConf, 63 | }, 64 | } 65 | 66 | res, err := c.Do(r) 67 | if err != nil { 68 | return "", fmt.Errorf("request failed: %v\n", err) 69 | } 70 | defer res.Body.Close() 71 | 72 | body, err := io.ReadAll(res.Body) 73 | if err != nil { 74 | return "", fmt.Errorf("cannot read body: %v\n", err) 75 | } 76 | 77 | var t token 78 | if err := json.Unmarshal(body, &t); err != nil { 79 | return "", fmt.Errorf("cannot unmarshal token : %v\n", err) 80 | } 81 | 82 | return t.IDToken, nil 83 | } 84 | 85 | func getContainerName(t *testing.T, tt testType, serviceName string) string { 86 | switch tt { 87 | case logs: 88 | return envLogsName + "-" + serviceName 89 | case metrics: 90 | return envMetricsName + "-" + serviceName 91 | case rules: 92 | return envRulesAPIName + "-" + serviceName 93 | case alerts: 94 | return envAlertmanagerName + "-" + serviceName 95 | case tenants: 96 | return envTenantsName + "-" + serviceName 97 | case interactive: 98 | return envInteractive + "-" + serviceName 99 | case traces: 100 | return envTracesName + "-" + serviceName 101 | case tracesTemplate: 102 | return envTracesTemplateName + "-" + serviceName 103 | case tracesTempo: 104 | return envTracesTempoName + "-" + serviceName 105 | default: 106 | t.Fatal("invalid test type provided") 107 | return "" 108 | } 109 | } 110 | 111 | func getTLSClientConfig(t *testing.T, e e2e.Environment) *tls.Config { 112 | cert, err := os.ReadFile(filepath.Join(e.SharedDir(), certsSharedDir, "ca.pem")) 113 | testutil.Ok(t, err) 114 | 115 | cp := x509.NewCertPool() 116 | cp.AppendCertsFromPEM(cert) 117 | 118 | return &tls.Config{ 119 | RootCAs: cp, 120 | } 121 | } 122 | 123 | func assertResponse(t *testing.T, response string, expected string) { 124 | testutil.Assert( 125 | t, 126 | strings.Contains(response, expected), 127 | fmt.Sprintf("failed to assert that the response '%s' contains '%s'", response, expected), 128 | ) 129 | } 130 | 131 | type tokenRoundTripper struct { 132 | rt http.RoundTripper 133 | token string 134 | } 135 | 136 | func (rt *tokenRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { 137 | r.Header.Add("Authorization", "bearer "+rt.token) 138 | return rt.rt.RoundTrip(r) 139 | } 140 | -------------------------------------------------------------------------------- /test/e2e/interactive_test.go: -------------------------------------------------------------------------------- 1 | //go:build interactive 2 | 3 | package e2e 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/efficientgo/core/testutil" 10 | "github.com/efficientgo/e2e" 11 | e2einteractive "github.com/efficientgo/e2e/interactive" 12 | ) 13 | 14 | func TestInteractiveSetup(t *testing.T) { 15 | fmt.Printf("Starting services...\n") 16 | 17 | e, err := e2e.New(e2e.WithName(envInteractive)) 18 | testutil.Ok(t, err) 19 | t.Cleanup(e.Close) 20 | 21 | prepareConfigsAndCerts(t, interactive, e) 22 | _, token, rateLimiterAddr := startBaseServices(t, e, interactive) 23 | readEndpoint, writeEndpoint, readExtEndpoint := startServicesForMetrics(t, e) 24 | logsEndpoint, logsExtEndpoint := startServicesForLogs(t, e) 25 | rulesEndpoint := startServicesForRules(t, e) 26 | internalOTLPGRPCEndpoint, internalOTLPHTTPEndpoint, httpExternalQueryEndpoint, httpInternalQueryEndpoint := startServicesForTraces(t, e) 27 | 28 | api, err := newObservatoriumAPIService( 29 | e, 30 | withMetricsEndpoints("http://"+readEndpoint, "http://"+writeEndpoint), 31 | withLogsEndpoints("http://"+logsEndpoint), 32 | withRulesEndpoint("http://"+rulesEndpoint), 33 | withRateLimiter(rateLimiterAddr), 34 | withGRPCListenEndpoint(":8317"), 35 | withOTLPGRPCTraceEndpoint(internalOTLPGRPCEndpoint), 36 | withOTLPHTTPTraceEndpoint(internalOTLPHTTPEndpoint), 37 | withJaegerEndpoint("http://"+httpInternalQueryEndpoint), 38 | ) 39 | testutil.Ok(t, err) 40 | testutil.Ok(t, e2e.StartAndWaitReady(api)) 41 | 42 | up, err := newUpRun( 43 | e, "up-metrics-read-write", metrics, 44 | "https://"+api.InternalEndpoint("https")+"/api/metrics/v1/"+defaultTenantName+"/api/v1/query", 45 | "https://"+api.InternalEndpoint("https")+"/api/metrics/v1/"+defaultTenantName+"/api/v1/receive", 46 | withToken(token), 47 | withRunParameters(&runParams{period: "5000ms", threshold: "1", latency: "10s", duration: "0"}), 48 | ) 49 | testutil.Ok(t, err) 50 | testutil.Ok(t, e2e.StartAndWaitReady(up)) 51 | 52 | testutil.Ok(t, e2einteractive.OpenInBrowser("http://"+readExtEndpoint)) 53 | 54 | up, err = newUpRun( 55 | e, "up-logs-read-write", logs, 56 | "https://"+api.InternalEndpoint("https")+"/api/logs/v1/"+defaultTenantName+"/loki/api/v1/query", 57 | "https://"+api.InternalEndpoint("https")+"/api/logs/v1/"+defaultTenantName+"/loki/api/v1/push", 58 | withToken(token), 59 | withRunParameters(&runParams{initialDelay: "100ms", period: "1s", threshold: "1", latency: "10s", duration: "0"}), 60 | ) 61 | testutil.Ok(t, err) 62 | testutil.Ok(t, e2e.StartAndWaitReady(up)) 63 | 64 | fmt.Printf("\n") 65 | fmt.Printf("You're all set up!\n") 66 | fmt.Printf("========================================\n") 67 | fmt.Printf("Observatorium API on host machine: %s \n", api.Endpoint("https")) 68 | fmt.Printf("Observatorium internal server on host machine: %s \n", api.Endpoint("http-internal")) 69 | fmt.Printf("Thanos Query on host machine: %s \n", readExtEndpoint) 70 | fmt.Printf("Loki on host machine: %s \n", logsExtEndpoint) 71 | fmt.Printf("Observatorium gRPC API on host machine: %s\n", api.Endpoint("grpc")) 72 | fmt.Printf("Jaeger Query on host machine (HTTP): %s\n", httpExternalQueryEndpoint) 73 | 74 | fmt.Printf("API Token: %s \n\n", token) 75 | 76 | testutil.Ok(t, e2einteractive.RunUntilEndpointHit()) 77 | } 78 | -------------------------------------------------------------------------------- /test/e2e/logs_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package e2e 4 | 5 | import ( 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "testing" 10 | 11 | e2emon "github.com/efficientgo/e2e/monitoring" 12 | 13 | "github.com/efficientgo/core/testutil" 14 | "github.com/efficientgo/e2e" 15 | "github.com/gorilla/websocket" 16 | ) 17 | 18 | func TestLogs(t *testing.T) { 19 | t.Parallel() 20 | 21 | e, err := e2e.New(e2e.WithName(envLogsName)) 22 | testutil.Ok(t, err) 23 | t.Cleanup(e.Close) 24 | 25 | prepareConfigsAndCerts(t, logs, e) 26 | _, token, rateLimiterAddr := startBaseServices(t, e, logs) 27 | logsEndpoint, logsExtEndpoint := startServicesForLogs(t, e) 28 | 29 | api, err := newObservatoriumAPIService( 30 | e, 31 | withLogsEndpoints("http://"+logsEndpoint), 32 | withRateLimiter(rateLimiterAddr), 33 | ) 34 | testutil.Ok(t, err) 35 | testutil.Ok(t, e2e.StartAndWaitReady(api)) 36 | 37 | t.Run("read-write", func(t *testing.T) { 38 | up, err := newUpRun( 39 | e, "up-logs-read-write", logs, 40 | "https://"+api.InternalEndpoint("https")+"/api/logs/v1/test-mtls/loki/api/v1/query", 41 | "https://"+api.InternalEndpoint("https")+"/api/logs/v1/test-mtls/loki/api/v1/push", 42 | withToken(token), 43 | withRunParameters(&runParams{initialDelay: "100ms", period: "1s", threshold: "1", latency: "10s", duration: "0"}), 44 | ) 45 | testutil.Ok(t, err) 46 | testutil.Ok(t, e2e.StartAndWaitReady(up)) 47 | 48 | // Check that up metrics are correct. 49 | testutil.Ok(t, up.WaitSumMetricsWithOptions( 50 | e2emon.GreaterOrEqual(5), 51 | []string{"up_queries_total"}, 52 | e2emon.WaitMissingMetrics(), 53 | )) 54 | 55 | testutil.Ok(t, up.WaitSumMetricsWithOptions( 56 | e2emon.GreaterOrEqual(12), 57 | []string{"up_remote_writes_total"}, 58 | e2emon.WaitMissingMetrics(), 59 | )) 60 | 61 | testutil.Ok(t, up.Kill()) 62 | 63 | // Check that API metrics are correct. 64 | testutil.Ok(t, api.WaitSumMetricsWithOptions( 65 | e2emon.GreaterOrEqual(24), 66 | []string{"http_requests_total"}, 67 | e2emon.WaitMissingMetrics(), 68 | )) 69 | 70 | // Simple test to check if we can query Loki for logs. 71 | r, err := http.NewRequest( 72 | http.MethodGet, 73 | "http://"+logsExtEndpoint+"/loki/api/v1/query", 74 | nil, 75 | ) 76 | testutil.Ok(t, err) 77 | 78 | v := url.Values{} 79 | v.Add("query", "{_id=\"test\"}") 80 | r.URL.RawQuery = v.Encode() 81 | r.Header.Add("X-Scope-OrgID", mtlsTenantID) 82 | 83 | res, err := http.DefaultClient.Do(r) 84 | testutil.Ok(t, err) 85 | defer res.Body.Close() 86 | 87 | body, err := io.ReadAll(res.Body) 88 | testutil.Ok(t, err) 89 | 90 | bodyStr := string(body) 91 | assertResponse(t, bodyStr, "\"__name__\":\"observatorium_write\"") 92 | assertResponse(t, bodyStr, "\"_id\":\"test\"") 93 | assertResponse(t, bodyStr, "log line 1") 94 | 95 | }) 96 | 97 | t.Run("tail-write", func(t *testing.T) { 98 | up, err := newUpRun( 99 | e, "up-logs-tail", logs, 100 | "https://"+api.InternalEndpoint("https")+"/api/logs/v1/"+defaultTenantName+"/loki/api/v1/query", 101 | "https://"+api.InternalEndpoint("https")+"/api/logs/v1/"+defaultTenantName+"/loki/api/v1/push", 102 | withToken(token), 103 | withRunParameters(&runParams{initialDelay: "0s", period: "250ms", threshold: "1", latency: "10s", duration: "0"}), 104 | ) 105 | testutil.Ok(t, err) 106 | testutil.Ok(t, e2e.StartAndWaitReady(up)) 107 | 108 | // Wait until the first query is run. 109 | testutil.Ok(t, up.WaitSumMetricsWithOptions( 110 | e2emon.GreaterOrEqual(1), 111 | []string{"up_queries_total"}, 112 | e2emon.WaitMissingMetrics(), 113 | )) 114 | 115 | testutil.Ok(t, up.Stop()) 116 | 117 | d := websocket.Dialer{TLSClientConfig: getTLSClientConfig(t, e)} 118 | conn, _, err := d.Dial( 119 | "wss://"+api.Endpoint("https")+"/api/logs/v1/"+defaultTenantName+"/loki/api/v1/tail?query=%7B_id%3D%22test%22%7D", 120 | http.Header{ 121 | "Authorization": []string{"Bearer " + token}, 122 | "X-Scope-OrgID": []string{defaultTenantID}, 123 | }, 124 | ) 125 | testutil.Ok(t, err) 126 | defer conn.Close() 127 | 128 | _, message, err := conn.ReadMessage() 129 | testutil.Ok(t, err) 130 | 131 | messageStr := string(message) 132 | assertResponse(t, messageStr, "\"__name__\":\"observatorium_write\"") 133 | assertResponse(t, messageStr, "\"_id\":\"test\"") 134 | assertResponse(t, messageStr, "log line 1") 135 | }) 136 | } 137 | -------------------------------------------------------------------------------- /test/e2e/redis_rate_limiter_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package e2e 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | "time" 9 | 10 | "github.com/efficientgo/core/backoff" 11 | "github.com/efficientgo/core/testutil" 12 | "github.com/efficientgo/e2e" 13 | 14 | "github.com/observatorium/api/ratelimit" 15 | ) 16 | 17 | func TestRedisRateLimiter_GetRateLimits(t *testing.T) { 18 | t.Parallel() 19 | // Start isolated environment with given ref. 20 | e, err := e2e.New(e2e.WithName("redis-rate-li")) 21 | testutil.Ok(t, err) 22 | t.Cleanup(e.Close) 23 | 24 | redis := createRedisContainer(e) 25 | t.Cleanup(func() { _ = redis.Stop() }) 26 | err = e2e.StartAndWaitReady(redis) 27 | testutil.Ok(t, err) 28 | 29 | type args struct { 30 | ctx context.Context 31 | req *ratelimit.Request 32 | } 33 | tests := []struct { 34 | name string 35 | args args 36 | totalHits int64 37 | wantRemaining int64 38 | // wantResetTimeFunc is used to calculate the expected reset time just before the hits are sent to the rate limiter. 39 | wantResetTimeFunc func() time.Time 40 | wantErr error 41 | // waitBeforeLastHit is used to wait the given amount of time and then make a last hit on the rate limiter. 42 | waitBeforeLastHit time.Duration 43 | }{ 44 | { 45 | name: "Single hit, far from limit", 46 | args: args{ 47 | ctx: context.Background(), 48 | req: &ratelimit.Request{ 49 | Key: "single-hit", 50 | Limit: 10, 51 | Duration: (10 * time.Second).Milliseconds(), 52 | }, 53 | }, 54 | totalHits: 1, 55 | wantRemaining: 9, 56 | wantResetTimeFunc: func() time.Time { 57 | return time.Now().Add(1 * time.Second) 58 | }, 59 | }, 60 | { 61 | name: "At the edge of the limit", 62 | args: args{ 63 | ctx: context.Background(), 64 | req: &ratelimit.Request{ 65 | Key: "edge-hit", 66 | Limit: 10, 67 | Duration: (10 * time.Second).Milliseconds(), 68 | }, 69 | }, 70 | totalHits: 10, 71 | wantRemaining: 0, 72 | wantResetTimeFunc: func() time.Time { 73 | return time.Now().Add(10 * time.Second) 74 | }, 75 | }, 76 | { 77 | name: "Beyond the limit", 78 | args: args{ 79 | ctx: context.Background(), 80 | req: &ratelimit.Request{ 81 | Key: "beyond-limit", 82 | Limit: 10, 83 | Duration: (10 * time.Second).Milliseconds(), 84 | }, 85 | }, 86 | totalHits: 11, 87 | wantRemaining: 0, 88 | wantErr: ratelimit.ErrOverLimit, 89 | wantResetTimeFunc: func() time.Time { 90 | return time.Now().Add(10 * time.Second) 91 | }, 92 | }, 93 | { 94 | // The test scenario is: 95 | // 1. Hit the rate limiter 2 times. No big amount of time should pass between the hits. 96 | // This ensures the bucket doesn't leak. 97 | // 2. Wait for 2 seconds. This means the bucket will leak 2 tokens. 98 | // 3. Hit the rate limiter 1 time. This should succeed. 99 | // If the bucket didn't leak, this would get total remaining of 7. 100 | // The reset time should be 3 seconds from the first hit. 101 | name: "Wait for 1 leak", 102 | args: args{ 103 | ctx: context.Background(), 104 | req: &ratelimit.Request{ 105 | Key: "wait-for-leak", 106 | Limit: 10, 107 | Duration: (10 * time.Second).Milliseconds(), 108 | }, 109 | }, 110 | totalHits: 2, 111 | // Waits for 2 seconds instead of 1 because of rounding in the algorithm. 112 | waitBeforeLastHit: 2 * time.Second, 113 | wantRemaining: 9, 114 | wantResetTimeFunc: func() time.Time { 115 | return time.Now().Add(3 * time.Second) 116 | }, 117 | }, 118 | } 119 | 120 | for _, tt := range tests { 121 | tt := tt // Can be removed when Go version >= 1.22 is set in the go.mod file. 122 | t.Run(tt.name, func(t *testing.T) { 123 | t.Parallel() 124 | b := backoff.New(context.Background(), backoff.Config{ 125 | Min: 100 * time.Millisecond, 126 | Max: 1 * time.Second, 127 | MaxRetries: 5, 128 | }) 129 | 130 | var ( 131 | err error 132 | r *ratelimit.RedisRateLimiter 133 | ) 134 | for b.Reset(); b.Ongoing(); b.Wait() { 135 | r, err = ratelimit.NewRedisRateLimiter([]string{redis.Endpoint("http")}) 136 | } 137 | testutil.Ok(t, err) 138 | testutil.Assert(t, r != nil) 139 | 140 | var gotRemaining, gotResetTime int64 141 | wantResetTime := tt.wantResetTimeFunc() 142 | for i := int64(0); i < tt.totalHits; i++ { 143 | gotRemaining, gotResetTime, err = r.GetRateLimits(tt.args.ctx, tt.args.req) 144 | } 145 | if tt.waitBeforeLastHit > 0 { 146 | time.Sleep(tt.waitBeforeLastHit) 147 | gotRemaining, gotResetTime, err = r.GetRateLimits(tt.args.ctx, tt.args.req) 148 | } 149 | 150 | testutil.Equals(t, tt.wantErr, err) 151 | testutil.Equals(t, tt.wantRemaining, gotRemaining) 152 | 153 | parsedGotResetTime := time.UnixMilli(gotResetTime) 154 | timeDifference := parsedGotResetTime.Sub(wantResetTime).Seconds() 155 | 156 | testutil.Assert(t, -1 <= timeDifference && timeDifference <= 1, "gotResetTime should be within 1 second of wantResetTime, it was %f seconds off", timeDifference) 157 | }) 158 | } 159 | } 160 | 161 | func createRedisContainer(env e2e.Environment) e2e.Runnable { 162 | return env.Runnable("redis").WithPorts(map[string]int{"http": 6379}).Init( 163 | e2e.StartOptions{ 164 | Image: "redis", 165 | Readiness: e2e.NewCmdReadinessProbe(e2e.Command{Cmd: "redis-cli", Args: []string{"ping"}}), 166 | }, 167 | ) 168 | } 169 | -------------------------------------------------------------------------------- /test/e2e/tenants_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package e2e 4 | 5 | import ( 6 | "testing" 7 | 8 | e2emon "github.com/efficientgo/e2e/monitoring" 9 | 10 | "github.com/efficientgo/core/testutil" 11 | "github.com/efficientgo/e2e" 12 | "github.com/efficientgo/e2e/monitoring/matchers" 13 | ) 14 | 15 | func TestTenantsRetryAuthenticationProviderRegistration(t *testing.T) { 16 | t.Parallel() 17 | 18 | e, err := e2e.New(e2e.WithName(envTenantsName)) 19 | testutil.Ok(t, err) 20 | t.Cleanup(e.Close) 21 | 22 | prepareConfigsAndCerts(t, tenants, e) 23 | dex, _, _ := startBaseServices(t, e, tenants) 24 | readEndpoint, writeEndpoint, _ := startServicesForMetrics(t, e) 25 | 26 | // Start API with stopped Dex and observe retries. 27 | dex.Stop() 28 | 29 | api, err := newObservatoriumAPIService( 30 | e, 31 | withMetricsEndpoints("http://"+readEndpoint, "http://"+writeEndpoint), 32 | ) 33 | testutil.Ok(t, err) 34 | testutil.Ok(t, e2e.StartAndWaitReady(api)) 35 | 36 | t.Run("tenants-authenticator-provider-retry", func(t *testing.T) { 37 | // Check that retries metric increases eventually. 38 | // This is with the new authenticators setup. 39 | testutil.Ok(t, api.WaitSumMetricsWithOptions( 40 | e2emon.Greater(0), 41 | []string{"observatorium_api_tenants_failed_registrations_total"}, 42 | e2emon.WaitMissingMetrics(), 43 | e2emon.WithLabelMatchers( 44 | matchers.MustNewMatcher(matchers.MatchEqual, "tenant", defaultTenantName), 45 | matchers.MustNewMatcher(matchers.MatchEqual, "provider", "oidc"), 46 | ), 47 | )) 48 | 49 | // Test a tenant with legacy configuration setup. 50 | testutil.Ok(t, api.WaitSumMetricsWithOptions( 51 | e2emon.Greater(0), 52 | []string{"observatorium_api_tenants_failed_registrations_total"}, 53 | e2emon.WaitMissingMetrics(), 54 | e2emon.WithLabelMatchers( 55 | matchers.MustNewMatcher(matchers.MatchEqual, "tenant", "test-attacker"), 56 | matchers.MustNewMatcher(matchers.MatchEqual, "provider", "oidc"), 57 | ), 58 | )) 59 | 60 | // Restart Dex. 61 | testutil.Ok(t, e2e.StartAndWaitReady(dex)) 62 | token, err := obtainToken(dex.Endpoint("https"), getTLSClientConfig(t, e)) 63 | testutil.Ok(t, err) 64 | 65 | up, err := newUpRun( 66 | e, "up-tenants", metrics, 67 | "https://"+api.InternalEndpoint("https")+"/api/metrics/v1/"+defaultTenantName+"/api/v1/query", 68 | "https://"+api.InternalEndpoint("https")+"/api/metrics/v1/"+defaultTenantName+"/api/v1/receive", 69 | withToken(token), 70 | withRunParameters(&runParams{initialDelay: "100ms", period: "300ms", threshold: "1", latency: "5s", duration: "0"}), 71 | ) 72 | testutil.Ok(t, err) 73 | testutil.Ok(t, e2e.StartAndWaitReady(up)) 74 | 75 | // Check that we successfully hit API after re-registration. 76 | testutil.Ok(t, api.WaitSumMetricsWithOptions( 77 | e2emon.Greater(0), 78 | []string{"http_requests_total"}, 79 | e2emon.WaitMissingMetrics(), 80 | e2emon.WithLabelMatchers( 81 | matchers.MustNewMatcher(matchers.MatchEqual, "code", "200"), 82 | ), 83 | )) 84 | 85 | }) 86 | 87 | } 88 | -------------------------------------------------------------------------------- /test/mock/provider.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "flag" 8 | "log" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/prometheus/prometheus/model/labels" 13 | "github.com/prometheus/prometheus/model/timestamp" 14 | "github.com/prometheus/prometheus/promql" 15 | "github.com/prometheus/prometheus/promql/parser" 16 | ) 17 | 18 | type response struct { 19 | Status string `json:"status"` 20 | Data interface{} `json:"data,omitempty"` 21 | ErrorType string `json:"errorType,omitempty"` 22 | Error string `json:"error,omitempty"` 23 | Warnings []string `json:"warnings,omitempty"` 24 | } 25 | type queryData struct { 26 | ResultType parser.ValueType `json:"resultType"` 27 | Result parser.Value `json:"result"` 28 | } 29 | 30 | var ( 31 | data = queryData{ 32 | ResultType: parser.ValueTypeScalar, 33 | Result: promql.Scalar{ 34 | V: 0.333, 35 | T: timestamp.FromTime(time.Unix(0, 0).Add(123 * time.Second)), 36 | }, 37 | } 38 | 39 | rangeData = queryData{ 40 | ResultType: parser.ValueTypeVector, 41 | Result: promql.Vector{ 42 | { 43 | Metric: labels.Labels{ 44 | { 45 | Name: "__name__", 46 | Value: "test_metric", 47 | }, 48 | { 49 | Name: "foo", 50 | Value: "bar", 51 | }, 52 | { 53 | Name: "replica", 54 | Value: "a", 55 | }, 56 | }, 57 | Point: promql.Point{ 58 | T: 123000, 59 | V: 2, 60 | }, 61 | }, 62 | { 63 | Metric: labels.Labels{ 64 | { 65 | Name: "__name__", 66 | Value: "test_metric", 67 | }, 68 | { 69 | Name: "foo", 70 | Value: "bar", 71 | }, 72 | { 73 | Name: "a", 74 | Value: "a", 75 | }, 76 | }, 77 | Point: promql.Point{ 78 | T: 123000, 79 | V: 2, 80 | }, 81 | }, 82 | { 83 | Metric: labels.Labels{ 84 | { 85 | Name: "__name__", 86 | Value: "test_metric", 87 | }, 88 | { 89 | Name: "foo", 90 | Value: "bar", 91 | }, 92 | { 93 | Name: "b", 94 | Value: "b", 95 | }, 96 | }, 97 | Point: promql.Point{ 98 | T: 123000, 99 | V: 2, 100 | }, 101 | }, 102 | }, 103 | } 104 | ) 105 | 106 | func main() { 107 | var listen string 108 | flag.StringVar(&listen, "listen", ":8888", "The address on which internal server runs.") 109 | 110 | http.HandleFunc("/query", queryHandler(data)) // TODO: Randomize results. 111 | http.HandleFunc("/query_range", queryHandler(rangeData)) // TODO: Randomize results. 112 | http.HandleFunc("/write", func(w http.ResponseWriter, r *http.Request) { 113 | w.WriteHeader(http.StatusOK) 114 | }) 115 | 116 | log.Println("start listening...") 117 | log.Fatal(http.ListenAndServe(listen, nil)) 118 | } 119 | 120 | func queryHandler(data queryData) func(w http.ResponseWriter, r *http.Request) { 121 | return func(w http.ResponseWriter, r *http.Request) { 122 | w.Header().Set("Content-Type", "application/json") 123 | w.Header().Set("Cache-Control", "no-store") 124 | w.WriteHeader(http.StatusOK) 125 | resp := &response{ 126 | Status: "success", 127 | Data: data, 128 | } 129 | if err := json.NewEncoder(w).Encode(resp); err != nil { 130 | w.WriteHeader(http.StatusInternalServerError) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /test/testtls/generate.go: -------------------------------------------------------------------------------- 1 | package testtls 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/x509" 10 | "crypto/x509/pkix" 11 | "encoding/pem" 12 | "fmt" 13 | "math/big" 14 | "net" 15 | "os" 16 | "path/filepath" 17 | "time" 18 | ) 19 | 20 | const expireDays = 1 21 | 22 | type certBundle struct { 23 | cert []byte 24 | key []byte 25 | } 26 | 27 | func GenerateCerts( 28 | path string, 29 | apiCommonName string, 30 | apiSANs []string, 31 | dexCommonName string, 32 | dexSANs []string, 33 | ) error { 34 | var ( 35 | caCommonName = "observatorium" 36 | clientCommonName = "up" 37 | clientSANs = "up" 38 | clientGroups = "test" 39 | ) 40 | 41 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 42 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 43 | if err != nil { 44 | return err 45 | } 46 | ca := &x509.Certificate{ 47 | SerialNumber: serialNumber, 48 | Subject: pkix.Name{ 49 | CommonName: caCommonName, 50 | }, 51 | NotBefore: time.Now(), 52 | NotAfter: time.Now().AddDate(0, 0, expireDays), 53 | IsCA: true, 54 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 55 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 56 | BasicConstraintsValid: true, 57 | } 58 | 59 | caPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 60 | if err != nil { 61 | return err 62 | } 63 | caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | caPEM := new(bytes.Buffer) 69 | if err := pem.Encode(caPEM, &pem.Block{ 70 | Type: "CERTIFICATE", 71 | Bytes: caBytes, 72 | }); err != nil { 73 | return err 74 | } 75 | caPrivKeyPEM := new(bytes.Buffer) 76 | key, err := x509.MarshalECPrivateKey(caPrivKey) 77 | if err != nil { 78 | return err 79 | } 80 | if err := pem.Encode(caPrivKeyPEM, &pem.Block{ 81 | Type: "EC PRIVATE KEY", 82 | Bytes: key, 83 | }); err != nil { 84 | return err 85 | } 86 | caBundle := certBundle{ 87 | cert: caPEM.Bytes(), 88 | key: caPrivKeyPEM.Bytes(), 89 | } 90 | 91 | apiBundle, err := generateCert(ca, caPrivKey, false, apiCommonName, apiSANs, nil) 92 | if err != nil { 93 | return err 94 | } 95 | dexBundle, err := generateCert(ca, caPrivKey, false, dexCommonName, dexSANs, nil) 96 | if err != nil { 97 | return err 98 | } 99 | clientBundle, err := generateCert(ca, caPrivKey, true, clientCommonName, []string{clientSANs}, []string{clientGroups}) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | for file, content := range map[string][]byte{ 105 | "ca.key": caBundle.key, 106 | "ca.pem": caBundle.cert, 107 | "server.key": apiBundle.key, 108 | "server.pem": apiBundle.cert, 109 | "dex.key": dexBundle.key, 110 | "dex.pem": dexBundle.cert, 111 | "client.key": clientBundle.key, 112 | "client.pem": clientBundle.cert, 113 | } { 114 | // Write certificates 115 | if err := os.MkdirAll(path, 0750); err != nil { 116 | return fmt.Errorf("mkdir %s: %v", path, err) 117 | } 118 | 119 | if err := os.WriteFile(filepath.Join(path, file), content, 0644); err != nil { 120 | return fmt.Errorf("write file %s: %v", file, err) 121 | } 122 | } 123 | return nil 124 | } 125 | 126 | func generateCert(caCert *x509.Certificate, caPrivateKey crypto.Signer, client bool, commonName string, dnsNames []string, ou []string) (*certBundle, error) { 127 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 128 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 129 | if err != nil { 130 | return nil, err 131 | } 132 | apiCert := &x509.Certificate{ 133 | SerialNumber: serialNumber, 134 | Subject: pkix.Name{ 135 | CommonName: commonName, 136 | OrganizationalUnit: ou, 137 | }, 138 | IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, 139 | NotBefore: time.Now(), 140 | 141 | NotAfter: time.Now().AddDate(0, 0, expireDays), 142 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 143 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, 144 | DNSNames: dnsNames, 145 | BasicConstraintsValid: true, 146 | IsCA: false, 147 | AuthorityKeyId: caCert.SubjectKeyId, 148 | } 149 | if client { 150 | apiCert.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} 151 | } 152 | certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 153 | if err != nil { 154 | return nil, err 155 | } 156 | certBytes, err := x509.CreateCertificate(rand.Reader, apiCert, caCert, &certPrivateKey.PublicKey, caPrivateKey) 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | certPEM := new(bytes.Buffer) 162 | if err := pem.Encode(certPEM, &pem.Block{ 163 | Type: "CERTIFICATE", 164 | Bytes: certBytes, 165 | }); err != nil { 166 | return nil, err 167 | } 168 | 169 | key, err := x509.MarshalECPrivateKey(certPrivateKey) 170 | if err != nil { 171 | return nil, err 172 | } 173 | certPrivateKeyPEM := new(bytes.Buffer) 174 | if err = pem.Encode(certPrivateKeyPEM, &pem.Block{ 175 | Type: "EC PRIVATE KEY", 176 | Bytes: key, 177 | }); err != nil { 178 | return nil, err 179 | } 180 | return &certBundle{ 181 | cert: certPEM.Bytes(), 182 | key: certPrivateKeyPEM.Bytes(), 183 | }, nil 184 | } 185 | -------------------------------------------------------------------------------- /tls/ca_watcher.go: -------------------------------------------------------------------------------- 1 | package tls 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "crypto/x509" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "sync" 12 | "time" 13 | 14 | "github.com/go-kit/log" 15 | "github.com/go-kit/log/level" 16 | ) 17 | 18 | // caCertificateWatcher poll for changes on the CA certificate file, if the CA change it will add it to the certificate Pool. 19 | type caCertificateWatcher struct { 20 | mutex sync.RWMutex 21 | certPool *x509.CertPool 22 | logger log.Logger 23 | fileHashContent string 24 | CAPath string 25 | interval time.Duration 26 | } 27 | 28 | // newCACertificateWatcher creates a new watcher for the CA file. 29 | func newCACertificateWatcher(CAPath string, logger log.Logger, interval time.Duration, pool *x509.CertPool) (*caCertificateWatcher, error) { 30 | w := &caCertificateWatcher{ 31 | CAPath: CAPath, 32 | logger: logger, 33 | certPool: pool, 34 | interval: interval, 35 | } 36 | err := w.loadCA() 37 | return w, err 38 | } 39 | 40 | // Watch for the changes on the certificate each interval, if the content changes 41 | // a new certificate will be added to the pool. 42 | func (w *caCertificateWatcher) Watch(ctx context.Context) error { 43 | var timer *time.Timer 44 | 45 | scheduleNext := func() { 46 | timer = time.NewTimer(w.interval) 47 | } 48 | scheduleNext() 49 | 50 | for { 51 | select { 52 | case <-ctx.Done(): 53 | return nil 54 | case <-timer.C: 55 | err := w.loadCA() 56 | if err != nil { 57 | return err 58 | } 59 | scheduleNext() 60 | } 61 | } 62 | 63 | } 64 | 65 | func (w *caCertificateWatcher) loadCA() error { 66 | 67 | hash, err := w.hashFile(w.CAPath) 68 | if err != nil { 69 | level.Error(w.logger).Log("unable to read the file", "error", err.Error()) 70 | return err 71 | } 72 | 73 | // If file changed 74 | if w.fileHashContent != hash { 75 | // read content 76 | caPEM, err := os.ReadFile(filepath.Clean(w.CAPath)) 77 | if err != nil { 78 | level.Error(w.logger).Log("failed to load CA %s: %w", w.CAPath, err) 79 | return err 80 | } 81 | w.mutex.Lock() 82 | defer w.mutex.Unlock() 83 | if !w.certPool.AppendCertsFromPEM(caPEM) { 84 | level.Error(w.logger).Log("failed to parse CA %s", w.CAPath) 85 | return err 86 | } 87 | } 88 | return nil 89 | } 90 | 91 | func (w *caCertificateWatcher) pool() *x509.CertPool { 92 | w.mutex.RLock() 93 | defer w.mutex.RUnlock() 94 | return w.certPool 95 | } 96 | 97 | // hashFile returns the SHA256 hash of the file. 98 | func (w *caCertificateWatcher) hashFile(file string) (string, error) { 99 | f, err := os.Open(filepath.Clean(file)) 100 | if err != nil { 101 | return "", err 102 | } 103 | 104 | defer f.Close() 105 | 106 | h := sha256.New() 107 | if _, err := io.Copy(h, f); err != nil { 108 | return "", err 109 | } 110 | 111 | return fmt.Sprintf("%x", h.Sum(nil)), nil 112 | } 113 | -------------------------------------------------------------------------------- /tls/ca_watcher_test.go: -------------------------------------------------------------------------------- 1 | package tls 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/pem" 9 | "fmt" 10 | "io" 11 | "os" 12 | "sync" 13 | "testing" 14 | "time" 15 | 16 | "github.com/observatorium/api/logger" 17 | "github.com/stretchr/testify/assert" 18 | "github.com/stretchr/testify/require" 19 | certutil "k8s.io/client-go/util/cert" 20 | ) 21 | 22 | func TestCertWatcher(t *testing.T) { 23 | logger := logger.NewLogger("info", logger.LogFormatLogfmt, "") 24 | reloadInterval := 2 * time.Second 25 | 26 | caA, caPathA, cleanupA, err := newSelfSignedCA("ok") 27 | defer cleanupA() 28 | require.NoError(t, err) 29 | caPool := x509.NewCertPool() 30 | caPool.AddCert(caA) 31 | 32 | reloader, err := newCACertificateWatcher(caPathA, logger, reloadInterval, caPool) 33 | require.NoError(t, err) 34 | 35 | cancelContext, cancel := context.WithCancel(context.Background()) 36 | 37 | var wg sync.WaitGroup 38 | wg.Add(1) 39 | 40 | go func() { 41 | err := reloader.Watch(cancelContext) 42 | require.NoError(t, err) 43 | wg.Done() 44 | }() 45 | // Start watch loop 46 | 47 | // Generate new CA 48 | caB, caPathB, cleanupB, err := newSelfSignedCA("baz") 49 | defer cleanupB() 50 | require.NoError(t, err) 51 | 52 | cbPool := x509.NewCertPool() 53 | cbPool.AddCert(caB) 54 | err = swapCert(t, caPathA, caPathB) 55 | require.NoError(t, err) 56 | 57 | rootCAs := x509.NewCertPool() 58 | rootCAs.AddCert(caA) 59 | rootCAs.AddCert(caB) 60 | 61 | assert.Eventually(t, func() bool { 62 | return rootCAs.Equal(reloader.pool()) 63 | 64 | }, 5*reloadInterval, reloadInterval) 65 | 66 | cancel() 67 | wg.Wait() 68 | 69 | } 70 | 71 | func newSelfSignedCA(hostname string) (*x509.Certificate, string, func(), error) { 72 | privKey, err := rsa.GenerateKey(rand.Reader, 2048) 73 | if err != nil { 74 | return nil, "", func() {}, fmt.Errorf("generation of private key failed: %v", err) 75 | } 76 | 77 | ca, err := certutil.NewSelfSignedCACert(certutil.Config{CommonName: hostname}, privKey) 78 | if err != nil { 79 | return nil, "", func() {}, fmt.Errorf("generation of certificate, failed: %v", err) 80 | } 81 | 82 | // Create a PEM block with the certificate 83 | pemBytes := pem.EncodeToMemory(&pem.Block{ 84 | Type: "CERTIFICATE", 85 | Bytes: ca.Raw, 86 | }) 87 | 88 | certPath, err := writeTempFile("cert", pemBytes) 89 | if err != nil { 90 | return nil, "", func() {}, fmt.Errorf("error writing cert data: %v", err) 91 | } 92 | 93 | return ca, certPath, func() { 94 | _ = os.Remove(certPath) 95 | }, nil 96 | } 97 | 98 | func writeTempFile(pattern string, data []byte) (string, error) { 99 | f, err := os.CreateTemp("", pattern) 100 | if err != nil { 101 | return "", fmt.Errorf("error creating temp file: %v", err) 102 | } 103 | defer f.Close() 104 | 105 | n, err := f.Write(data) 106 | if err == nil && n < len(data) { 107 | err = io.ErrShortWrite 108 | } 109 | 110 | if err != nil { 111 | return "", fmt.Errorf("error writing temporary file: %v", err) 112 | } 113 | 114 | return f.Name(), nil 115 | } 116 | 117 | func swapCert(t *testing.T, caPathA, caPathB string) error { 118 | t.Log("renaming", caPathB, "to", caPathA) 119 | if err := os.Rename(caPathB, caPathA); err != nil { 120 | return err 121 | } 122 | return nil 123 | } 124 | -------------------------------------------------------------------------------- /tls/config.go: -------------------------------------------------------------------------------- 1 | package tls 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | 7 | "github.com/go-kit/log" 8 | "github.com/go-kit/log/level" 9 | ) 10 | 11 | // NewServerConfig provides new server TLS configuration. 12 | func NewServerConfig(logger log.Logger, certFile, keyFile, minVersion, maxVersion, clientAuthType string, cipherSuites []string) (*tls.Config, error) { 13 | if certFile == "" && keyFile == "" { 14 | level.Info(logger).Log("msg", "TLS disabled; key and cert must be set to enable") 15 | 16 | return nil, nil 17 | } 18 | 19 | level.Info(logger).Log("msg", "enabling server side TLS") 20 | 21 | tlsCert, err := tls.LoadX509KeyPair(certFile, keyFile) 22 | if err != nil { 23 | return nil, fmt.Errorf("server credentials: %w", err) 24 | } 25 | 26 | tlsMinVersion, err := parseTLSVersion(minVersion) 27 | if err != nil { 28 | return nil, fmt.Errorf("cannot parse TLS Version: %w", err) 29 | } 30 | 31 | tlsMaxVersion, err := parseTLSVersion(maxVersion) 32 | if err != nil { 33 | return nil, fmt.Errorf("cannot parse TLS Version: %w", err) 34 | } 35 | 36 | if tlsMinVersion > tlsMaxVersion { 37 | return nil, fmt.Errorf("TLS minimum version can not be greater than maximum version: %v > %v", tlsMinVersion, tlsMaxVersion) 38 | } 39 | 40 | cipherSuiteIDs, err := mapCipherNamesToIDs(cipherSuites) 41 | if err != nil { 42 | return nil, fmt.Errorf("TLS cipher suite name to ID conversion: %v", err) 43 | } 44 | 45 | tlsClientAuthType, err := parseClientAuthType(clientAuthType) 46 | if err != nil { 47 | return nil, fmt.Errorf("can not parse TLS Client authentication policy: %w", err) 48 | } 49 | 50 | tlsCfg := &tls.Config{ 51 | Certificates: []tls.Certificate{tlsCert}, 52 | // A list of supported cipher suites for TLS versions up to TLS 1.2. 53 | // If CipherSuites is nil, a default list of secure cipher suites is used. 54 | // Note that TLS 1.3 ciphersuites are not configurable. 55 | CipherSuites: cipherSuiteIDs, 56 | ClientAuth: tlsClientAuthType, 57 | MinVersion: tlsMinVersion, 58 | MaxVersion: tlsMaxVersion, 59 | } 60 | 61 | return tlsCfg, nil 62 | } 63 | 64 | func tlsCipherSuites() map[string]uint16 { 65 | cipherSuites := map[string]uint16{} 66 | 67 | for _, suite := range tls.CipherSuites() { 68 | cipherSuites[suite.Name] = suite.ID 69 | } 70 | for _, suite := range tls.InsecureCipherSuites() { 71 | cipherSuites[suite.Name] = suite.ID 72 | } 73 | 74 | return cipherSuites 75 | } 76 | 77 | func parseTLSVersion(rawTLSVersion string) (uint16, error) { 78 | switch rawTLSVersion { 79 | case "VersionTLS10": 80 | return tls.VersionTLS10, nil 81 | case "VersionTLS11": 82 | return tls.VersionTLS11, nil 83 | case "VersionTLS12": 84 | return tls.VersionTLS12, nil 85 | case "VersionTLS13": 86 | return tls.VersionTLS13, nil 87 | default: 88 | return 0, fmt.Errorf("unknown TLSVersion: %s", rawTLSVersion) 89 | } 90 | } 91 | 92 | func mapCipherNamesToIDs(rawTLSCipherSuites []string) ([]uint16, error) { 93 | if rawTLSCipherSuites == nil { 94 | return nil, nil 95 | } 96 | 97 | cipherSuites := []uint16{} 98 | allCipherSuites := tlsCipherSuites() 99 | 100 | for _, name := range rawTLSCipherSuites { 101 | id, ok := allCipherSuites[name] 102 | if !ok { 103 | return nil, fmt.Errorf("unknown TLSCipherSuite: %s", name) 104 | } 105 | cipherSuites = append(cipherSuites, id) 106 | } 107 | 108 | return cipherSuites, nil 109 | } 110 | 111 | func parseClientAuthType(rawAuthType string) (tls.ClientAuthType, error) { 112 | switch rawAuthType { 113 | case "NoClientCert": 114 | return tls.NoClientCert, nil 115 | case "RequestClientCert": 116 | return tls.RequestClientCert, nil 117 | case "RequireAnyClientCert": 118 | return tls.RequireAnyClientCert, nil 119 | case "VerifyClientCertIfGiven": 120 | return tls.VerifyClientCertIfGiven, nil 121 | case "RequireAndVerifyClientCert": 122 | return tls.RequireAndVerifyClientCert, nil 123 | default: 124 | return 0, fmt.Errorf("unknown ClientAuthType: %s", rawAuthType) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tls/options.go: -------------------------------------------------------------------------------- 1 | package tls 2 | 3 | import ( 4 | "context" 5 | stdtls "crypto/tls" 6 | "crypto/x509" 7 | "os" 8 | "time" 9 | 10 | rbacproxytls "github.com/brancz/kube-rbac-proxy/pkg/tls" 11 | "github.com/go-kit/log" 12 | "github.com/oklog/run" 13 | ) 14 | 15 | // UpstreamOptions represents the options of the upstream TLS configuration 16 | // this structure contains the certificates and the watchers if the certificate/ca watchers are enabled. 17 | type UpstreamOptions struct { 18 | cert *stdtls.Certificate 19 | ca []byte 20 | certReloader *rbacproxytls.CertReloader 21 | caReloader *caCertificateWatcher 22 | } 23 | 24 | // NewUpstreamOptions create a new UpstreamOptions, if interval is nil, the watcher will not be enabled. 25 | func NewUpstreamOptions(ctx context.Context, upstreamCertFile, upstreamKeyFile, upstreamCAFile string, 26 | interval *time.Duration, logger log.Logger, g run.Group) (*UpstreamOptions, error) { 27 | 28 | // reload enabled 29 | if interval != nil { 30 | return newWithWatchers(ctx, upstreamCertFile, upstreamKeyFile, upstreamCAFile, *interval, logger, g) 31 | } 32 | 33 | return newNoWatchers(upstreamCertFile, upstreamKeyFile, upstreamCAFile) 34 | } 35 | 36 | func newWithWatchers(ctx context.Context, upstreamCertFile, upstreamKeyFile, upstreamCAFile string, 37 | interval time.Duration, logger log.Logger, g run.Group) (*UpstreamOptions, error) { 38 | options := &UpstreamOptions{} 39 | 40 | if upstreamCertFile != "" && upstreamKeyFile != "" { 41 | certReloader, err := startCertReloader(ctx, g, upstreamCertFile, upstreamKeyFile, interval) 42 | if err != nil { 43 | return nil, err 44 | } 45 | options.certReloader = certReloader 46 | } 47 | if upstreamCAFile != "" { 48 | caPool := x509.NewCertPool() 49 | caReloader, err := startCAReloader(ctx, g, upstreamCAFile, interval, logger, caPool) 50 | if err != nil { 51 | return nil, err 52 | } 53 | options.caReloader = caReloader 54 | } 55 | return options, nil 56 | } 57 | 58 | func newNoWatchers(upstreamCertFile, upstreamKeyFile, upstreamCAFile string) (*UpstreamOptions, error) { 59 | options := &UpstreamOptions{} 60 | if upstreamCertFile != "" && upstreamKeyFile != "" { 61 | cert, err := stdtls.LoadX509KeyPair(upstreamCertFile, upstreamKeyFile) 62 | if err != nil { 63 | return nil, err 64 | } 65 | options.cert = &cert 66 | } 67 | 68 | if upstreamCAFile != "" { 69 | ca, err := os.ReadFile(upstreamCAFile) 70 | if err != nil { 71 | return nil, err 72 | } 73 | options.ca = ca 74 | } 75 | return options, nil 76 | } 77 | 78 | func startCertReloader(ctx context.Context, g run.Group, 79 | upstreamKeyFile, upstreamCertFile string, interval time.Duration) (*rbacproxytls.CertReloader, error) { 80 | certReloader, err := rbacproxytls.NewCertReloader( 81 | upstreamKeyFile, 82 | upstreamCertFile, 83 | interval, 84 | ) 85 | if err != nil { 86 | return nil, err 87 | } 88 | ctx, cancel := context.WithCancel(ctx) 89 | g.Add(func() error { 90 | return certReloader.Watch(ctx) 91 | }, func(error) { 92 | cancel() 93 | }) 94 | return certReloader, nil 95 | } 96 | 97 | func startCAReloader(ctx context.Context, g run.Group, upstreamCAFile string, interval time.Duration, logger log.Logger, 98 | pool *x509.CertPool) (*caCertificateWatcher, error) { 99 | caReloader, err := newCACertificateWatcher(upstreamCAFile, logger, interval, pool) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | ctx, cancel := context.WithCancel(ctx) 105 | g.Add(func() error { 106 | return caReloader.Watch(ctx) 107 | }, func(error) { 108 | cancel() 109 | }) 110 | return caReloader, nil 111 | } 112 | 113 | // hasCA determine if the CA was specified. 114 | func (uo *UpstreamOptions) hasCA() bool { 115 | return len(uo.ca) != 0 || uo.caReloader != nil 116 | } 117 | 118 | // hasUpstreamCerts determine if the hasUpstreamCerts were specified. 119 | func (uo *UpstreamOptions) hasUpstreamCerts() bool { 120 | return uo.cert != nil || uo.certReloader != nil 121 | } 122 | 123 | // isCAReloadEnabled determine if the CA watcher is enabled. 124 | func (uo *UpstreamOptions) isCAReloadEnabled() bool { 125 | return uo.caReloader != nil 126 | } 127 | 128 | // isCertReloaderEnabled determine if the certificate watcher is enabled. 129 | func (uo *UpstreamOptions) isCertReloaderEnabled() bool { 130 | return uo.certReloader != nil 131 | } 132 | 133 | // NewClientConfig returns a tls config for the reverse proxy handling if an upstream CA is given. 134 | // this will transform TLS UpstreamOptions to a tls.Config native TLS golang structure, if the watchers are enabled 135 | // it will override the GetClientCertificate function. 136 | func (uo *UpstreamOptions) NewClientConfig() *stdtls.Config { 137 | if !uo.hasCA() { 138 | return nil 139 | } 140 | cfg := &stdtls.Config{} 141 | 142 | if uo.hasUpstreamCerts() { 143 | if uo.isCertReloaderEnabled() { 144 | cfg.GetClientCertificate = func(info *stdtls.CertificateRequestInfo) (*stdtls.Certificate, error) { 145 | return uo.certReloader.GetCertificate(nil) 146 | } 147 | } else { 148 | cfg.Certificates = append(cfg.Certificates, *uo.cert) 149 | } 150 | } 151 | 152 | if uo.isCAReloadEnabled() { 153 | cfg.RootCAs = uo.caReloader.pool() 154 | } else { 155 | cfg.RootCAs = x509.NewCertPool() 156 | cfg.RootCAs.AppendCertsFromPEM(uo.ca) 157 | } 158 | return cfg 159 | } 160 | -------------------------------------------------------------------------------- /tracing/tracing.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | propjaeger "go.opentelemetry.io/contrib/propagators/jaeger" 8 | "go.opentelemetry.io/otel" 9 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" 10 | "go.opentelemetry.io/otel/propagation" 11 | "go.opentelemetry.io/otel/sdk/resource" 12 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 13 | semconv "go.opentelemetry.io/otel/semconv/v1.5.0" 14 | "go.opentelemetry.io/otel/trace/noop" 15 | ) 16 | 17 | // InitTracer creates an OTel TracerProvider that exports the traces to a Jaeger agent/collector. 18 | func InitTracer( 19 | serviceName string, 20 | endpoint string, 21 | samplingFraction float64, 22 | ) (err error) { 23 | if endpoint == "" { 24 | otel.SetTracerProvider(noop.NewTracerProvider()) 25 | return nil 26 | } 27 | 28 | exporter, err := otlptracehttp.New(context.Background(), otlptracehttp.WithEndpointURL(endpoint)) 29 | if err != nil { 30 | return fmt.Errorf("failed to create otlp exporter: %w", err) 31 | } 32 | 33 | tp := sdktrace.NewTracerProvider( 34 | sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(samplingFraction))), 35 | sdktrace.WithBatcher(exporter), 36 | sdktrace.WithResource( 37 | resource.NewWithAttributes( 38 | semconv.SchemaURL, 39 | semconv.ServiceNameKey.String(serviceName), 40 | )), 41 | ) 42 | 43 | otel.SetTracerProvider(tp) 44 | otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( 45 | propagation.TraceContext{}, 46 | propjaeger.Jaeger{}, 47 | propagation.Baggage{}, 48 | )) 49 | 50 | return nil 51 | } 52 | --------------------------------------------------------------------------------