├── .bingo ├── .gitignore ├── README.md ├── Variables.mk ├── faillint.mod ├── faillint.sum ├── go.mod ├── goimports.mod ├── goimports.sum ├── golangci-lint.mod ├── golangci-lint.sum ├── mdox.mod ├── mdox.sum ├── misspell.mod ├── misspell.sum ├── promu.mod ├── promu.sum └── variables.env ├── .circleci └── config.yml ├── .errcheck_excludes.txt ├── .github └── workflows │ ├── docs.yaml │ └── go.yaml ├── .gitignore ├── .golangci.yml ├── .mdox.validate.yaml ├── .promu.yml ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── docs └── release.md ├── go.mod ├── go.sum ├── main.go ├── obsctlcontext.png ├── pkg ├── cmd │ ├── cmd.go │ ├── cmd_test.go │ ├── context.go │ ├── login.go │ ├── logout.go │ ├── logs.go │ ├── metrics.go │ ├── testdata │ │ ├── go_gc_duration_seconds.json │ │ ├── go_gc_duration_seconds.png │ │ ├── go_gc_duration_seconds.txt │ │ ├── prometheus_engine_query_duration_seconds.json │ │ ├── prometheus_engine_query_duration_seconds.png │ │ └── prometheus_engine_query_duration_seconds.txt │ └── traces.go ├── config │ ├── config.go │ └── config_test.go ├── fetcher │ └── request.go ├── proxy │ └── proxy.go └── version │ └── version.go ├── scripts └── build-check-comments.sh └── test ├── config └── loki.yml └── e2e ├── configs.go ├── helpers.go ├── hydra-for-macOS.yaml ├── hydra.yaml ├── kill_hydra.sh ├── obsctl_test.go ├── services.go └── start_hydra.sh /.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/Variables.mk: -------------------------------------------------------------------------------- 1 | # Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.6. DO NOT EDIT. 2 | # All tools are designed to be build inside $GOBIN. 3 | BINGO_DIR := $(dir $(lastword $(MAKEFILE_LIST))) 4 | GOPATH ?= $(shell go env GOPATH) 5 | GOBIN ?= $(firstword $(subst :, ,${GOPATH}))/bin 6 | GO ?= $(shell which go) 7 | 8 | # Below generated variables ensure that every time a tool under each variable is invoked, the correct version 9 | # will be used; reinstalling only if needed. 10 | # For example for faillint variable: 11 | # 12 | # In your main Makefile (for non array binaries): 13 | # 14 | #include .bingo/Variables.mk # Assuming -dir was set to .bingo . 15 | # 16 | #command: $(FAILLINT) 17 | # @echo "Running faillint" 18 | # @$(FAILLINT) 19 | # 20 | FAILLINT := $(GOBIN)/faillint-v1.11.0 21 | $(FAILLINT): $(BINGO_DIR)/faillint.mod 22 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 23 | @echo "(re)installing $(GOBIN)/faillint-v1.11.0" 24 | @cd $(BINGO_DIR) && $(GO) build -mod=mod -modfile=faillint.mod -o=$(GOBIN)/faillint-v1.11.0 "github.com/fatih/faillint" 25 | 26 | GOIMPORTS := $(GOBIN)/goimports-v0.1.7 27 | $(GOIMPORTS): $(BINGO_DIR)/goimports.mod 28 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 29 | @echo "(re)installing $(GOBIN)/goimports-v0.1.7" 30 | @cd $(BINGO_DIR) && $(GO) build -mod=mod -modfile=goimports.mod -o=$(GOBIN)/goimports-v0.1.7 "golang.org/x/tools/cmd/goimports" 31 | 32 | GOLANGCI_LINT := $(GOBIN)/golangci-lint-v1.44.0 33 | $(GOLANGCI_LINT): $(BINGO_DIR)/golangci-lint.mod 34 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 35 | @echo "(re)installing $(GOBIN)/golangci-lint-v1.44.0" 36 | @cd $(BINGO_DIR) && $(GO) build -mod=mod -modfile=golangci-lint.mod -o=$(GOBIN)/golangci-lint-v1.44.0 "github.com/golangci/golangci-lint/cmd/golangci-lint" 37 | 38 | MDOX := $(GOBIN)/mdox-v0.9.0 39 | $(MDOX): $(BINGO_DIR)/mdox.mod 40 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 41 | @echo "(re)installing $(GOBIN)/mdox-v0.9.0" 42 | @cd $(BINGO_DIR) && $(GO) build -mod=mod -modfile=mdox.mod -o=$(GOBIN)/mdox-v0.9.0 "github.com/bwplotka/mdox" 43 | 44 | MISSPELL := $(GOBIN)/misspell-v0.3.4 45 | $(MISSPELL): $(BINGO_DIR)/misspell.mod 46 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 47 | @echo "(re)installing $(GOBIN)/misspell-v0.3.4" 48 | @cd $(BINGO_DIR) && $(GO) build -mod=mod -modfile=misspell.mod -o=$(GOBIN)/misspell-v0.3.4 "github.com/client9/misspell/cmd/misspell" 49 | 50 | PROMU := $(GOBIN)/promu-v0.5.0 51 | $(PROMU): $(BINGO_DIR)/promu.mod 52 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 53 | @echo "(re)installing $(GOBIN)/promu-v0.5.0" 54 | @cd $(BINGO_DIR) && $(GO) build -mod=mod -modfile=promu.mod -o=$(GOBIN)/promu-v0.5.0 "github.com/prometheus/promu" 55 | 56 | -------------------------------------------------------------------------------- /.bingo/faillint.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.17 4 | 5 | require github.com/fatih/faillint v1.11.0 6 | -------------------------------------------------------------------------------- /.bingo/faillint.sum: -------------------------------------------------------------------------------- 1 | dmitri.shuralyov.com/go/generated v0.0.0-20170818220700-b1254a446363 h1:o4lAkfETerCnr1kF9/qwkwjICnU+YLHNDCM8h2xj7as= 2 | dmitri.shuralyov.com/go/generated v0.0.0-20170818220700-b1254a446363/go.mod h1:WG7q7swWsS2f9PYpt5DoEP/EBYWx8We5UoRltn9vJl8= 3 | github.com/fatih/faillint v1.8.0 h1:wV/mhyU+FcDtXx4RByy+H2FjrwHU9hEiFMyWPYmKqPE= 4 | github.com/fatih/faillint v1.8.0/go.mod h1:Yu1OT32SIjnX4Kn/h4/YPQOuNfuITtL3Gps70ac4lQk= 5 | github.com/fatih/faillint v1.11.0 h1:EhmAKe8k0Cx2gnf+/JiX/IAeeKjwsQao5dY8oG6cQB4= 6 | github.com/fatih/faillint v1.11.0/go.mod h1:d9kdQwFcr+wD4cLXOdjTw1ENUUvv5+z0ctJ5Wm0dTvA= 7 | github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 8 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 9 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 10 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 11 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 12 | golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= 13 | golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= 14 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= 15 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 16 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 17 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 18 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 19 | golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 20 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 21 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 22 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 23 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 24 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 25 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 26 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0= 30 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= 33 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 35 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 36 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 37 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 38 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 39 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 40 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 41 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 42 | golang.org/x/tools v0.1.8 h1:P1HhGGuLW4aAclzjtmJdf0mJOjVUZUzOTqkAkWL+l6w= 43 | golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= 44 | golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= 45 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 46 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 47 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 48 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 49 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 50 | -------------------------------------------------------------------------------- /.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.17 4 | 5 | require golang.org/x/tools v0.1.7 // cmd/goimports 6 | -------------------------------------------------------------------------------- /.bingo/goimports.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= 2 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA= 3 | golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ= 4 | golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= 5 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 6 | -------------------------------------------------------------------------------- /.bingo/golangci-lint.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.17 4 | 5 | require github.com/golangci/golangci-lint v1.44.0 // cmd/golangci-lint 6 | -------------------------------------------------------------------------------- /.bingo/mdox.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.17 4 | 5 | require github.com/bwplotka/mdox v0.9.0 6 | -------------------------------------------------------------------------------- /.bingo/misspell.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.17 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/promu.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.18 4 | 5 | require github.com/prometheus/promu v0.5.0 6 | -------------------------------------------------------------------------------- /.bingo/promu.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= 3 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 4 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= 5 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 6 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 7 | github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= 8 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 12 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 13 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 14 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 15 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 16 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 17 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 18 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 19 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 20 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 21 | github.com/google/go-github/v25 v25.0.0 h1:y/oM3M5B1Y5wUD2lFU6qRVwxFGU580oy/2zPFBQxCCc= 22 | github.com/google/go-github/v25 v25.0.0/go.mod h1:XMRvWvLBf2K0UaSNFDIBtB5BgBYRV5g+0b7k32sqrME= 23 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 24 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 25 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 26 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 27 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 28 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 29 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 30 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 31 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 32 | github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= 33 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 34 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 35 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 36 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 37 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 38 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 39 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 42 | github.com/prometheus/client_golang v1.0.0 h1:vrDKnkGzuGvhNAL56c7DBz29ZL+KxnoR0x7enabFceM= 43 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 44 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 45 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= 46 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 47 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 48 | github.com/prometheus/common v0.5.0 h1:2znmQeLeqnfKh7s5Tdg2bjfRzmVBD6JMp6SmWZHwU1E= 49 | github.com/prometheus/common v0.5.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= 50 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 51 | github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNGfs= 52 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 53 | github.com/prometheus/promu v0.5.0 h1:q7GkmIdBZ+ulL+6v4EDsZL+cW9UCW9J3DHA89bFI83c= 54 | github.com/prometheus/promu v0.5.0/go.mod h1:sXydR89lpo0YkCrYK1EhYjaJUesenzLhd9CNRAwN+bI= 55 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 56 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 57 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 58 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 59 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 60 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 61 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 62 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 63 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 64 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 65 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 66 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980 h1:dfGZHvZk057jK2MCeWus/TowKpJ8y4AmooUzdBSR9GU= 67 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 68 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 69 | golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a h1:tImsplftrFpALCYumobsd0K86vlAs/eXGFms2txfJfA= 70 | golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 71 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 72 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 73 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 74 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 75 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 76 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 77 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 78 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 79 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 80 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= 81 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 82 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 83 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 84 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 85 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 86 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 87 | -------------------------------------------------------------------------------- /.bingo/variables.env: -------------------------------------------------------------------------------- 1 | # Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.6. 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 | FAILLINT="${GOBIN}/faillint-v1.11.0" 12 | 13 | GOIMPORTS="${GOBIN}/goimports-v0.1.7" 14 | 15 | GOLANGCI_LINT="${GOBIN}/golangci-lint-v1.44.0" 16 | 17 | MDOX="${GOBIN}/mdox-v0.9.0" 18 | 19 | MISSPELL="${GOBIN}/misspell-v0.3.4" 20 | 21 | PROMU="${GOBIN}/promu-v0.5.0" 22 | 23 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | go: circleci/go@1.7.1 5 | 6 | executors: 7 | golang: 8 | docker: 9 | - image: cimg/go:1.17-node 10 | 11 | jobs: 12 | # Cross build is needed for publish_release but needs to be done outside of docker. 13 | cross_build: 14 | machine: true 15 | working_directory: /home/circleci/.go_workspace/src/github.com/observatorium/obsctl 16 | environment: 17 | GOBIN: "/home/circleci/.go_workspace/go/bin" 18 | PROMU_VERSION: "0.5.0" 19 | steps: 20 | - checkout 21 | - run: mkdir -p ${GOBIN} 22 | - run: curl -L "https://github.com/prometheus/promu/releases/download/v${PROMU_VERSION}/promu-${PROMU_VERSION}.$(go env GOOS)-$(go env GOARCH).tar.gz" | tar --strip-components=1 -xzf - -C ${GOBIN} 23 | - run: mv -f ${GOBIN}/promu "${GOBIN}/promu-v${PROMU_VERSION}" 24 | - run: make crossbuild -W ${GOBIN}/promu-v${PROMU_VERSION} # Ignore make dependency, it needs to be enforced somehow. 25 | - persist_to_workspace: 26 | root: . 27 | paths: 28 | - .build 29 | 30 | publish_release: 31 | executor: golang 32 | steps: 33 | - checkout 34 | - go/mod-download-cached 35 | - setup_remote_docker: 36 | version: 20.10.12 37 | - attach_workspace: 38 | at: . 39 | - run: make tarballs-release 40 | - store_artifacts: 41 | path: .tarballs 42 | destination: releases 43 | 44 | # Only run on tags 45 | workflows: 46 | version: 2 47 | obsctl: 48 | jobs: 49 | - cross_build: 50 | filters: 51 | tags: 52 | only: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/ 53 | branches: 54 | ignore: /.*/ 55 | - publish_release: 56 | requires: 57 | - cross_build 58 | filters: 59 | tags: 60 | only: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/ 61 | branches: 62 | ignore: /.*/ 63 | -------------------------------------------------------------------------------- /.errcheck_excludes.txt: -------------------------------------------------------------------------------- 1 | (github.com/go-kit/log.Logger).Log 2 | fmt.Fprintln 3 | fmt.Fprint -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | name: Documentation check 13 | env: 14 | GOBIN: /tmp/.bin 15 | steps: 16 | - name: Checkout code into the Go module directory. 17 | uses: actions/checkout@v2 18 | 19 | - name: Install Go 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: 1.17.x 23 | 24 | - uses: actions/cache@v1 25 | with: 26 | path: ~/go/pkg/mod 27 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 28 | 29 | - name: Check docs 30 | run: make check-docs -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: go 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | name: Linters (Static Analysis) for Go 13 | steps: 14 | - name: Checkout code into the Go module directory. 15 | uses: actions/checkout@v2 16 | 17 | - name: Install Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.17.x 21 | 22 | - uses: actions/cache@v1 23 | with: 24 | path: ~/go/pkg/mod 25 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 26 | 27 | - name: Linting & vetting. 28 | env: 29 | GOBIN: /tmp/.bin 30 | run: make lint 31 | tests: 32 | runs-on: ${{ matrix.platform }} 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | go: [ '1.17.x'] 37 | platform: [ubuntu-latest] 38 | 39 | name: Tests on Go ${{ matrix.go }} ${{ matrix.platform }} 40 | steps: 41 | - name: Checkout code into the Go module directory. 42 | uses: actions/checkout@v2 43 | 44 | - name: Install Go 45 | uses: actions/setup-go@v2 46 | with: 47 | go-version: ${{ matrix.go }} 48 | 49 | - uses: actions/cache@v1 50 | with: 51 | path: ~/go/pkg/mod 52 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 53 | 54 | - name: Run unit tests. 55 | env: 56 | GOBIN: /tmp/.bin 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | run: make test 59 | 60 | - name: Run e2e tests. 61 | env: 62 | GOBIN: /tmp/.bin 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | run: make test-e2e -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | obsctl 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | vendor/ 17 | .bin 18 | 19 | .idea/ 20 | .envrc 21 | 22 | test/e2e/e2e_* 23 | test/e2e/tmp 24 | 25 | .vscode/* 26 | 27 | # Ignore promu artifacts. 28 | /.build 29 | /.release 30 | /.tarballs -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # This file contains all available configuration options 2 | # with their default values. 3 | 4 | # options for analysis running 5 | run: 6 | # timeout for analysis, e.g. 30s, 5m, default is 1m 7 | deadline: 5m 8 | 9 | # exit code when at least one issue was found, default is 1 10 | issues-exit-code: 1 11 | 12 | # which dirs to skip: they won't be analyzed; 13 | # can use regexp here: generated.*, regexp is applied on full path; 14 | # default value is empty list, but next dirs are always skipped independently 15 | # from this option's value: 16 | # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ 17 | skip-dirs: vendor 18 | 19 | # output configuration options 20 | output: 21 | # colored-line-number|line-number|json|tab|checkstyle, default is "colored-line-number" 22 | format: colored-line-number 23 | 24 | # print lines of code with issue, default is true 25 | print-issued-lines: true 26 | 27 | # print linter name in the end of issue text, default is true 28 | print-linter-name: true 29 | 30 | linters-settings: 31 | errcheck: 32 | exclude: ./.errcheck_excludes.txt 33 | misspell: 34 | locale: US 35 | goconst: 36 | min-occurrences: 5 37 | -------------------------------------------------------------------------------- /.mdox.validate.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | validators: 4 | - regex: 'localhost' 5 | type: 'ignore' -------------------------------------------------------------------------------- /.promu.yml: -------------------------------------------------------------------------------- 1 | go: 2 | version: 1.17 3 | repository: 4 | path: github.com/observatorium/obsctl 5 | build: 6 | binaries: 7 | - name: obsctl 8 | path: ./ 9 | flags: -a -tags netgo 10 | crossbuild: 11 | platforms: 12 | - linux/amd64 13 | - darwin/amd64 14 | - linux/arm64 15 | - windows/amd64 16 | - freebsd/amd64 17 | - linux/ppc64le 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include .bingo/Variables.mk 2 | FILES_TO_FMT ?= $(shell find . -path ./vendor -prune -o -name '*.go' -print) 3 | MDOX_VALIDATE_CONFIG ?= .mdox.validate.yaml 4 | 5 | GO111MODULE ?= on 6 | export GO111MODULE 7 | 8 | GOBIN ?= $(firstword $(subst :, ,${GOPATH}))/bin 9 | 10 | # Tools. 11 | GIT ?= $(shell which git) 12 | 13 | # Promu is using this exact variable name, do not rename. 14 | PREFIX ?= $(GOBIN) 15 | 16 | # Support gsed on OSX (installed via brew), falling back to sed. On Linux 17 | # systems gsed won't be installed, so will use sed as expected. 18 | SED ?= $(shell which gsed 2>/dev/null || which sed) 19 | 20 | define require_clean_work_tree 21 | @git update-index -q --ignore-submodules --refresh 22 | 23 | @if ! git diff-files --quiet --ignore-submodules --; then \ 24 | echo >&2 "$1: you have unstaged changes."; \ 25 | git diff-files --name-status -r --ignore-submodules -- >&2; \ 26 | echo >&2 "Please commit or stash them."; \ 27 | exit 1; \ 28 | fi 29 | 30 | @if ! git diff-index --cached --quiet HEAD --ignore-submodules --; then \ 31 | echo >&2 "$1: your index contains uncommitted changes."; \ 32 | git diff-index --cached --name-status -r --ignore-submodules HEAD -- >&2; \ 33 | echo >&2 "Please commit or stash them."; \ 34 | exit 1; \ 35 | fi 36 | 37 | endef 38 | 39 | help: ## Displays help. 40 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-10s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) 41 | 42 | .PHONY: all 43 | all: format build 44 | 45 | .PHONY: build 46 | build: ## Builds obsctl binary using `promu`. 47 | build: check-git deps $(PROMU) 48 | @echo ">> building obsctl binary in $(PREFIX)" 49 | @$(PROMU) build --prefix $(PREFIX) 50 | 51 | GIT_BRANCH=$(shell $(GIT) rev-parse --abbrev-ref HEAD) 52 | .PHONY: crossbuild 53 | crossbuild: ## Builds all binaries for all platforms. 54 | ifeq ($(GIT_BRANCH), main) 55 | crossbuild: | $(PROMU) 56 | @echo ">> crossbuilding all binaries" 57 | # we only care about below two for the main branch 58 | $(PROMU) crossbuild -v -p linux/amd64 -p linux/arm64 59 | else 60 | crossbuild: | $(PROMU) 61 | @echo ">> crossbuilding all binaries" 62 | $(PROMU) crossbuild -v 63 | endif 64 | 65 | .PHONY: tarballs-release 66 | tarballs-release: ## Build tarballs. 67 | tarballs-release: $(PROMU) 68 | @echo ">> Publishing tarballs" 69 | $(PROMU) crossbuild -v tarballs 70 | $(PROMU) checksum -v .tarballs 71 | $(PROMU) release -v .tarballs 72 | 73 | .PHONY: check-comments 74 | check-comments: ## Checks Go code comments if they have trailing period (excludes protobuffers and vendor files). Comments with more than 3 spaces at beginning are omitted from the check, example: '// - foo'. 75 | @echo ">> checking Go comments trailing periods\n\n\n" 76 | @./scripts/build-check-comments.sh 77 | 78 | .PHONY: deps 79 | deps: ## Ensures fresh go.mod and go.sum. 80 | @go mod tidy 81 | @go mod verify 82 | 83 | .PHONY: docs 84 | docs: build $(MDOX) ## Generates config snippets and doc formatting. 85 | @echo ">> generating docs $(PATH)" 86 | PATH=${PATH}:$(GOBIN) $(MDOX) fmt -l --links.validate.config-file=$(MDOX_VALIDATE_CONFIG) *.md 87 | 88 | .PHONY: check-docs 89 | check-docs: build $(MDOX) ## Checks docs for discrepancies in formatting and links. 90 | @echo ">> checking formatting and links $(PATH)" 91 | PATH=${PATH}:$(GOBIN) $(MDOX) fmt --check -l --links.validate.config-file=$(MDOX_VALIDATE_CONFIG) *.md 92 | 93 | .PHONY: format 94 | format: ## Formats Go code. 95 | format: $(GOIMPORTS) 96 | @echo ">> formatting code" 97 | @$(GOIMPORTS) -w $(FILES_TO_FMT) 98 | 99 | .PHONY: test 100 | test: ## Runs all Go unit tests. 101 | export GOCACHE=/tmp/cache 102 | test: 103 | @echo ">> running unit tests (without cache)" 104 | @rm -rf $(GOCACHE) 105 | @go test -v -timeout=30m $(shell go list ./... | grep -v e2e); 106 | 107 | .PHONY: check-git 108 | check-git: 109 | ifneq ($(GIT),) 110 | @test -x $(GIT) || (echo >&2 "No git executable binary found at $(GIT)."; exit 1) 111 | else 112 | @echo >&2 "No git binary found."; exit 1 113 | endif 114 | 115 | # PROTIP: 116 | # Add 117 | # --cpu-profile-path string Path to CPU profile output file 118 | # --mem-profile-path string Path to memory profile output file 119 | # to debug big allocations during linting. 120 | lint: ## Runs various static analysis against our code. 121 | lint: $(FAILLINT) $(GOLANGCI_LINT) $(MISSPELL) build format docs check-git deps 122 | $(call require_clean_work_tree,"detected not clean master before running lint") 123 | @echo ">> verifying modules being imported" 124 | @$(FAILLINT) -paths "fmt.{Print,Printf,Println},io/ioutil.{Discard,NopCloser,ReadAll,ReadDir,ReadFile,TempDir,TempFile,Writefile}" -ignore-tests ./... 125 | @echo ">> examining all of the Go files" 126 | @go vet -stdmethods=false ./... 127 | @echo ">> linting all of the Go files GOGC=${GOGC}" 128 | @$(GOLANGCI_LINT) run 129 | @echo ">> detecting misspells" 130 | @find . -type f | grep -v vendor/ | grep -vE '\./\..*' | xargs $(MISSPELL) -error 131 | 132 | .PHONY: test-e2e 133 | test-e2e: 134 | @rm -rf ./test/e2e/e2e_* 135 | @rm -rf ./test/e2e/tmp 136 | @go test -v -timeout 99m github.com/observatorium/obsctl/test/e2e 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # obsctl 2 | 3 | ![Tests](https://github.com/observatorium/obsctl/actions/workflows/go.yaml/badge.svg) [![Slack](https://img.shields.io/badge/join%20slack-%23observatorium-brightgreen.svg)](https://cloud-native.slack.com/archives/C0211ULK7BJ) 4 | 5 | A CLI to interact with Observatorium instances as a tenant and get/set various resources. 6 | 7 | ## Goals 8 | 9 | Currently, we do not have a simple and convenient way to interact with the Observatorium API from the command line. Manually crafting cURL commands while authenticating with OIDC or mTLS is not an optimal experience and is generally confusing/trickier for users, both old and new. 10 | 11 | `obsctl` aims to greatly simplifies this process by storing the configuration for multiple tenants and instances of the Observatorium API locally and allowing users to switch between them and perform operations. 12 | 13 | ## Features 14 | 15 | - Manage authentication configuration for multiple tenants/APIs by saving them locally 16 | - Allow users to switch between tenants/APIs conveniently 17 | - View metrics-based resources series, rules, and labels for a tenant 18 | - Configure and view Prometheus rules for a tenant 19 | 20 | `obsctl` also aims to support more such one-off operations for other domains as well (logs, traces, alert-routing configs) 21 | 22 | ## Design 23 | 24 | ![How obsctl manages configuration](obsctlcontext.png "How obsctl manages configuration") 25 | 26 | `obsctl` stores the details of APIs and tenants in a map in the user's config directory. 27 | - Each API is defined as a name and a URL 28 | - Tenants are then defined under each API 29 | - A current "context" is maintained which points to one API instance and a tenant under it 30 | - Users can switch between "contexts" and perform operations 31 | 32 | ## Installing 33 | 34 | Requirements for your system: 35 | 36 | - Go 1.17+ 37 | 38 | Install using, 39 | 40 | ```bash 41 | go install github.com/observatorium/obsctl@latest 42 | ``` 43 | 44 | or via [bingo](https://github.com/bwplotka/bingo) if you want to pin it, 45 | 46 | ```bash 47 | bingo get -l github.com/observatorium/obsctl@latest 48 | ``` 49 | 50 | ## Quickstart 51 | 52 | You can get started using obsctl in three simple steps, 53 | 54 | 1) Add an API: `obsctl context api add --name='example-staging-api' --url=''` 55 | 2) Login as tenant for the api: `obsctl login --api='example-staging-api' --oidc.audience='' --oidc.client-id='' --oidc.client-secret='' --oidc.issuer-url='' --tenant='example-tenant'` 56 | 3) Perform operations: `obsctl metrics get rules` 57 | 58 | ## Usage 59 | 60 | ```bash mdox-exec="obsctl --help" 61 | CLI to interact with Observatorium 62 | 63 | Usage: 64 | obsctl [command] 65 | 66 | Available Commands: 67 | completion Generate the autocompletion script for the specified shell 68 | context Manage context configuration. 69 | help Help about any command 70 | login Login as a tenant. Will also save tenant details locally. 71 | logout Logout a tenant. Will remove locally saved details. 72 | logs logs based operations for Observatorium. 73 | metrics Metrics based operations for Observatorium. 74 | traces Trace-based operations for Observatorium. 75 | 76 | Flags: 77 | -h, --help help for obsctl 78 | --log.format string Log format to use. (default "clilog") 79 | --log.level string Log filtering level. (default "info") 80 | -v, --version version for obsctl 81 | 82 | Use "obsctl [command] --help" for more information about a command. 83 | ``` 84 | 85 | ### Authentication 86 | 87 | Add an Observatorium API instance using the `obsctl context api add`, and provide a name for the API which can be used to refer to it later on. 88 | 89 | ```bash mdox-exec="obsctl context api add --help" 90 | Add API configuration. 91 | 92 | Usage: 93 | obsctl context api add [flags] 94 | 95 | Flags: 96 | -h, --help help for add 97 | --name string Provide an optional name to easily refer to the Observatorium Instance. 98 | --url string The URL for the Observatorium API. 99 | 100 | Global Flags: 101 | --log.format string Log format to use. (default "clilog") 102 | --log.level string Log filtering level. (default "info") 103 | ``` 104 | 105 | Then, simply login as a tenant under that API using `obsctl login`. Note that currently `obsctl` only supports [OIDC client-credentials](https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/) based flow. 106 | 107 | ```bash mdox-exec="obsctl login --help" 108 | Login as a tenant. Will also save tenant details locally. 109 | 110 | Usage: 111 | obsctl login [flags] 112 | 113 | Flags: 114 | --api string The name of the Observatorium API that has been saved previously. 115 | --ca string Path to the TLS CA against which to verify the Observatorium API. If no server CA is specified, the client will use the system certificates. 116 | --disable.oidc-check If set to true, OIDC flags will not be checked while saving tenant details locally. 117 | -h, --help help for login 118 | --oidc.audience string The audience for whom the access token is intended, see https://openid.net/specs/openid-connect-core-1_0.html#IDToken. 119 | --oidc.client-id string The OIDC client ID, see https://tools.ietf.org/html/rfc6749#section-2.3. 120 | --oidc.client-secret string The OIDC client secret, see https://tools.ietf.org/html/rfc6749#section-2.3. 121 | --oidc.issuer-url string The OIDC issuer URL, see https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery. 122 | --oidc.offline-access If set to false, oidc scope offline_access will not be requested, see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest (default true) 123 | --tenant string The name of the tenant. 124 | 125 | Global Flags: 126 | --log.format string Log format to use. (default "clilog") 127 | --log.level string Log filtering level. (default "info") 128 | ``` 129 | 130 | The first time you add an API and login as tenant, the current "context" will be set to the newly added API & tenant. You can see this by checking for the current context using `obsctl context current` or by listing all the saved contexts using `obsctl context list`. 131 | 132 | But after the first time, when you add another API/tenant you need to manually switch the context by using `obsctl context switch /`. 133 | 134 | ```bash mdox-exec="obsctl context --help" 135 | View/Manage context configuration. 136 | 137 | Usage: 138 | obsctl context [command] 139 | 140 | Available Commands: 141 | api Add/edit/remove API configuration. 142 | current View current context configuration. 143 | list View all context configuration. 144 | rm Remove context configuration. 145 | switch Switch to another context. 146 | 147 | Flags: 148 | -h, --help help for context 149 | 150 | Global Flags: 151 | --log.format string Log format to use. (default "clilog") 152 | --log.level string Log filtering level. (default "info") 153 | 154 | Use "obsctl context [command] --help" for more information about a command. 155 | ``` 156 | 157 | You can also remove a context by using `obsctl context rm /`. In case an API configuration does not have a tenant associated with it, the API configuration can be removed using `obsctl context api rm `. 158 | 159 | ### Metrics 160 | 161 | You can use `obsctl metrics` to get/set metrics-based resources. 162 | 163 | ```bash mdox-exec="obsctl metrics --help" 164 | Metrics based operations for Observatorium. 165 | 166 | Usage: 167 | obsctl metrics [command] 168 | 169 | Available Commands: 170 | get Read series, labels & rules (JSON/YAML) of a tenant. 171 | query Query metrics for a tenant. 172 | set Write Prometheus Rules configuration for a tenant. 173 | ui Starts a proxy server and opens a Thanos Query UI for making requests to Observatorium API as a tenant. 174 | 175 | Flags: 176 | -h, --help help for metrics 177 | 178 | Global Flags: 179 | --log.format string Log format to use. (default "clilog") 180 | --log.level string Log filtering level. (default "info") 181 | 182 | Use "obsctl metrics [command] --help" for more information about a command. 183 | ``` 184 | 185 | To view different types of resources use `obsctl metrics get`. 186 | 187 | ```bash mdox-exec="obsctl metrics get --help" 188 | Read series, labels & rules (JSON/YAML) of a tenant. 189 | 190 | Usage: 191 | obsctl metrics get [command] 192 | 193 | Available Commands: 194 | labels Get labels of a tenant. 195 | labelvalues Get label values of a tenant. 196 | rules Get rules of a tenant. 197 | rules.raw Get configured rules of a tenant. 198 | series Get series of a tenant. 199 | 200 | Flags: 201 | -h, --help help for get 202 | 203 | Global Flags: 204 | --log.format string Log format to use. (default "clilog") 205 | --log.level string Log filtering level. (default "info") 206 | 207 | Use "obsctl metrics get [command] --help" for more information about a command. 208 | ``` 209 | 210 | To set Prometheus Rules for a tenant you can use `obsctl metric set --rule.file=path/to/rules.yaml` (Support for setting other types of resources are planned). 211 | 212 | ```bash mdox-exec="obsctl metrics set --help" 213 | Write Prometheus Rules configuration for a tenant. 214 | 215 | Usage: 216 | obsctl metrics set [flags] 217 | 218 | Flags: 219 | -h, --help help for set 220 | --rule.file string Path to Rules configuration file, which will be set for a tenant. 221 | 222 | Global Flags: 223 | --log.format string Log format to use. (default "clilog") 224 | --log.level string Log filtering level. (default "info") 225 | ``` 226 | 227 | You can also execute a PromQL range or instant query and view the results as a JSON response using `obsctl metrics query `. 228 | 229 | ```bash mdox-exec="obsctl metrics query --help" 230 | Query metrics for a tenant. Can get results for both instant and range queries. Pass a single valid PromQL query to fetch results for. 231 | 232 | Usage: 233 | obsctl metrics query [flags] 234 | 235 | Examples: 236 | obsctl metrics query "prometheus_http_request_total" 237 | 238 | Flags: 239 | -e, --end string End timestamp. Must be provided if --range is true. 240 | --graph string If specified, range query result will output an (ascii|png) graph. 241 | -h, --help help for query 242 | --range If true, query will be evaluated as a range query. See https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries. 243 | -s, --start string Start timestamp. Must be provided if --range is true. 244 | --step string Query resolution step width. Only used if --range is provided. 245 | --time string Evaluation timestamp. Only used if --range is false. 246 | --timeout string Evaluation timeout. Optional. 247 | 248 | Global Flags: 249 | --log.format string Log format to use. (default "clilog") 250 | --log.level string Log filtering level. (default "info") 251 | ``` 252 | 253 | To execute a range query you can use the `--range` flag and provide the required options alongside the query. 254 | 255 | ### Logs 256 | 257 | You can use `obsctl logs` to get/set logs-based resources. 258 | 259 | ```bash mdox-exec="obsctl logs --help" 260 | logs based operations for Observatorium. 261 | 262 | Usage: 263 | obsctl logs [command] 264 | 265 | Available Commands: 266 | get Read series, labels & labels values (JSON/YAML) of a tenant. 267 | query Query logs for a tenant. 268 | set Write Loki Rules configuration for a tenant. 269 | 270 | Flags: 271 | -h, --help help for logs 272 | 273 | Global Flags: 274 | --log.format string Log format to use. (default "clilog") 275 | --log.level string Log filtering level. (default "info") 276 | 277 | Use "obsctl logs [command] --help" for more information about a command. 278 | ``` 279 | 280 | To view different types of resources use `obsctl logs get`. 281 | 282 | ```bash mdox-exec="obsctl logs get --help" 283 | Read series, labels & labels values (JSON/YAML) of a tenant. 284 | 285 | Usage: 286 | obsctl logs get [command] 287 | 288 | Available Commands: 289 | alerts Get alerts of a tenant. 290 | labels Get labels of a tenant. 291 | labelvalues Get label values of a tenant. 292 | rules Get rules of a tenant. 293 | rules.raw Get configured rules of a tenant. 294 | series Get series of a tenant. 295 | 296 | Flags: 297 | -h, --help help for get 298 | 299 | Global Flags: 300 | --log.format string Log format to use. (default "clilog") 301 | --log.level string Log filtering level. (default "info") 302 | 303 | Use "obsctl logs get [command] --help" for more information about a command. 304 | ``` 305 | 306 | You can also execute a LogQL range or instant query and view the results as a JSON response using `obsctl logs query `. 307 | 308 | ```bash mdox-exec="obsctl logs query --help" 309 | Query logs for a tenant. Can get results for both instant and range queries. Pass a single valid LogQl query to fetch results for. 310 | 311 | Usage: 312 | obsctl logs query [flags] 313 | 314 | Examples: 315 | obsctl logs query "prometheus_http_request_total" 316 | 317 | Flags: 318 | --direction string Determines the sort order of logs.. Only used if --range is false. 319 | -e, --end string End timestamp. Must be provided if --range is true. 320 | -h, --help help for query 321 | --interval string return entries at (or greater than) the specified interval,Only used if --range is provided. 322 | --limit float32 The max number of entries to return. Only used if --range is false. (default 100) 323 | --range If true, query will be evaluated as a range query. See https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries. 324 | -s, --start string Start timestamp. Must be provided if --range is true. 325 | --step string Query resolution step width. Only used if --range is provided. 326 | --time string Evaluation timestamp. Only used if --range is false. 327 | 328 | Global Flags: 329 | --log.format string Log format to use. (default "clilog") 330 | --log.level string Log filtering level. (default "info") 331 | ``` 332 | 333 | To execute a range query you can use the `--range` flag and provide the required options alongside the query. 334 | 335 | ## Future additons in obsctl 336 | - [ ] Add support for logging operations 337 | - [ ] Add support for tracing operations 338 | - [X] Add support for PromQL query execution 339 | - [ ] Add support for alerting configuration based on [proposal](https://github.com/observatorium/observatorium/pull/453) 340 | 341 | ## Contributing 342 | 343 | Any contributions are welcome! Please use GitHub Issues/Pull Requests as usual. Learn more on how to [get involved](https://github.com/observatorium/observatorium/blob/main/docs/community/get_involved.md)! 344 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v0.1.0-dev -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | # Release process 2 | 3 | We use semantic versioning for `obsctl` in format `vX.Y.Z`. 4 | 5 | We also ship binaries, read [docs on how to install](https://github.com/observatorium/obsctl#installing). Users can also use `go install` to install obsctl. 6 | 7 | We follow a simple release process as follows: 8 | 1. Create a PR against `main` to update the `version/version.go` and `VERSION` file to the appropriate next version (and remove the `-dev` suffix). 9 | - Normally, with the current (early) state of the project, we always target the next minor version (e.g. we bump from `v0.1.0` to `v0.2.0`), unless there is a compelling reason to release a **patch** version. 10 | - We also usually do not have release candidates, with the exception of the initial release. 11 | 2. Once the PR from 1. is approved and merged, start drafting a new release from the [GitHub web UI](https://github.com/observatorium/obsctl/releases/new). 12 | 3. Prepare a new **tag** that will be created together with release. Do this by clicking on `Choose a tag` -> put the new tag into the input field in format `vX.Y.Z`. 13 | 4. Prepare the release title and description. The release title should correspond to the release version. The description can be generated by clicking on `Auto-generate release notes`. If the release includes any significant changes, feel free to add a one-sentence summary at the top of the description. If there are any noisy or redundant entries in the auto-generated list, feel free to remove them. 14 | 5. CircleCI `publish_release` job should run and upload the release artifacts to the release. 15 | 6. Open a new PR against `main`, this time updating `version/version.go` and `VERSION` to the next minor release with `-dev` suffix (`vX.(Y+1).Z-dev`). -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/observatorium/obsctl 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/bwplotka/mdox v0.9.0 7 | github.com/coreos/go-oidc/v3 v3.2.0 8 | github.com/efficientgo/e2e v0.12.1 9 | github.com/efficientgo/tools/core v0.0.0-20220225185207-fe763185946b 10 | github.com/ghodss/yaml v1.0.0 11 | github.com/go-kit/log v0.2.1 12 | github.com/google/uuid v1.3.0 13 | github.com/guptarohit/asciigraph v0.5.5 14 | github.com/observatorium/api v0.1.3-0.20221005180515-c3230526775b 15 | github.com/oklog/run v1.1.0 16 | github.com/prometheus/common v0.37.0 17 | github.com/spf13/cobra v1.5.0 18 | github.com/wcharczuk/go-chart/v2 v2.1.0 19 | golang.org/x/oauth2 v0.0.0-20220718184931-c8730f7fcb92 20 | ) 21 | 22 | require ( 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/deepmap/oapi-codegen v1.11.0 // indirect 25 | github.com/go-kit/kit v0.12.0 // indirect 26 | github.com/go-logfmt/logfmt v0.5.1 // indirect 27 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 28 | github.com/golang/protobuf v1.5.2 // indirect 29 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 30 | github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect 31 | github.com/pkg/errors v0.9.1 // indirect 32 | github.com/pmezard/go-difflib v1.0.0 // indirect 33 | github.com/prometheus/client_model v0.2.0 // indirect 34 | github.com/spf13/pflag v1.0.5 // indirect 35 | go.uber.org/goleak v1.1.12 // indirect 36 | golang.org/x/crypto v0.0.0-20220824171710-5757bc0c5503 // indirect 37 | golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 // indirect 38 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect 39 | golang.org/x/net v0.0.0-20220708220712-1185a9018129 // indirect 40 | google.golang.org/appengine v1.6.7 // indirect 41 | google.golang.org/protobuf v1.28.1 // indirect 42 | gopkg.in/square/go-jose.v2 v2.6.0 // indirect 43 | gopkg.in/yaml.v2 v2.4.0 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "syscall" 7 | 8 | "github.com/observatorium/obsctl/pkg/cmd" 9 | "github.com/oklog/run" 10 | ) 11 | 12 | func main() { 13 | ctx, cancel := context.WithCancel(context.Background()) 14 | cmd := cmd.NewObsctlCmd(ctx) 15 | 16 | var g run.Group 17 | g.Add(func() error { 18 | return cmd.Execute() 19 | }, func(err error) { 20 | cancel() 21 | }) 22 | 23 | // Listen for termination signals. 24 | g.Add(run.SignalHandler(ctx, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)) 25 | 26 | if err := g.Run(); err != nil { 27 | os.Exit(1) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /obsctlcontext.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/obsctl/751b92bac586604482d120fc1de839c881bf410e/obsctlcontext.png -------------------------------------------------------------------------------- /pkg/cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/exec" 11 | "runtime" 12 | "strings" 13 | "time" 14 | 15 | "github.com/bwplotka/mdox/pkg/clilog" 16 | "github.com/ghodss/yaml" 17 | "github.com/go-kit/log" 18 | "github.com/go-kit/log/level" 19 | "github.com/guptarohit/asciigraph" 20 | "github.com/observatorium/api/client/models" 21 | "github.com/observatorium/obsctl/pkg/version" 22 | "github.com/prometheus/common/model" 23 | "github.com/spf13/cobra" 24 | "github.com/wcharczuk/go-chart/v2" 25 | ) 26 | 27 | const ( 28 | logFormatLogfmt = "logfmt" 29 | logFormatJson = "json" 30 | logFormatCLILog = "clilog" 31 | ) 32 | 33 | var logLevel, logFormat string 34 | var logger log.Logger 35 | 36 | func setupLogger(*cobra.Command, []string) { 37 | var lvl level.Option 38 | switch logLevel { 39 | case "error": 40 | lvl = level.AllowError() 41 | case "warn": 42 | lvl = level.AllowWarn() 43 | case "info": 44 | lvl = level.AllowInfo() 45 | case "debug": 46 | lvl = level.AllowDebug() 47 | default: 48 | panic("unexpected log level") 49 | } 50 | switch logFormat { 51 | case logFormatJson: 52 | logger = level.NewFilter(log.NewJSONLogger(log.NewSyncWriter(os.Stderr)), lvl) 53 | case logFormatLogfmt: 54 | logger = level.NewFilter(log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)), lvl) 55 | case logFormatCLILog: 56 | fallthrough 57 | default: 58 | logger = level.NewFilter(clilog.New(log.NewSyncWriter(os.Stderr)), lvl) 59 | } 60 | } 61 | 62 | func NewObsctlCmd(ctx context.Context) *cobra.Command { 63 | cmd := &cobra.Command{ 64 | Use: "obsctl", 65 | Short: "CLI to interact with Observatorium", 66 | Long: `CLI to interact with Observatorium`, 67 | Version: version.Version, 68 | PersistentPreRun: setupLogger, 69 | } 70 | 71 | cmd.AddCommand(NewMetricsCmd(ctx)) 72 | cmd.AddCommand(NewContextCommand(ctx)) 73 | cmd.AddCommand(NewLoginCmd(ctx)) 74 | cmd.AddCommand(NewLogoutCmd(ctx)) 75 | cmd.AddCommand(NewTracesCmd(ctx)) 76 | cmd.AddCommand(NewLogsCmd(ctx)) 77 | 78 | cmd.PersistentFlags().StringVar(&logLevel, "log.level", "info", "Log filtering level.") 79 | cmd.PersistentFlags().StringVar(&logFormat, "log.format", logFormatCLILog, "Log format to use.") 80 | 81 | return cmd 82 | } 83 | 84 | // prettyPrintJSON prints indented JSON to stdout. 85 | func prettyPrintJSON(b []byte) (string, error) { 86 | var out bytes.Buffer 87 | err := json.Indent(&out, b, "", "\t") 88 | if err != nil { 89 | level.Debug(logger).Log("msg", "failed indent", "json", string(b)) 90 | return "", fmt.Errorf("indent JSON %w", err) 91 | } 92 | 93 | return out.String(), nil 94 | } 95 | 96 | // prettyPrintYAML prints YAML as indented JSON to stdout. 97 | func prettyPrintYAML(b []byte) (string, error) { 98 | var out bytes.Buffer 99 | jb, err := yaml.YAMLToJSON(b) 100 | if err != nil { 101 | level.Debug(logger).Log("msg", "failed yaml to json conversion", "yaml", string(b)) 102 | return "", fmt.Errorf("conversion JSON %w", err) 103 | } 104 | 105 | err = json.Indent(&out, jb, "", "\t") 106 | if err != nil { 107 | level.Debug(logger).Log("msg", "failed indent", "json", string(b)) 108 | return "", fmt.Errorf("indent JSON %w", err) 109 | } 110 | 111 | return out.String(), nil 112 | } 113 | 114 | func handleResponse(body []byte, contentType string, statusCode int, cmd *cobra.Command) error { 115 | if statusCode/100 == 2 { 116 | switch contentType { 117 | case "application/json", "application/json; charset=UTF-8": 118 | json, err := prettyPrintJSON(body) 119 | if err != nil { 120 | return fmt.Errorf("request failed with status code %d pretty printing: %v", statusCode, err) 121 | } 122 | 123 | fmt.Fprintln(cmd.OutOrStdout(), json) 124 | return nil 125 | case "application/yaml": 126 | json, err := prettyPrintYAML(body) 127 | if err != nil { 128 | return fmt.Errorf("request failed with status code %d pretty printing: %v", statusCode, err) 129 | } 130 | 131 | fmt.Fprintln(cmd.OutOrStdout(), json) 132 | return nil 133 | } 134 | } 135 | 136 | if len(body) != 0 { 137 | // Pretty print only if we know the error response is JSON. 138 | // In future we might want to handle other types as well. 139 | switch contentType { 140 | case "application/json", "application/json; charset=UTF-8": 141 | jsonErr, err := prettyPrintJSON(body) 142 | if err != nil { 143 | return fmt.Errorf("request failed with status code %d pretty printing: %v", statusCode, err) 144 | } 145 | 146 | return fmt.Errorf(jsonErr) 147 | case "application/yaml": 148 | jsonErr, err := prettyPrintYAML(body) 149 | if err != nil { 150 | return fmt.Errorf("request failed with status code %d pretty printing: %v", statusCode, err) 151 | } 152 | 153 | return fmt.Errorf(jsonErr) 154 | default: 155 | return fmt.Errorf("request failed with status code %d, error: %s", statusCode, string(body)) 156 | } 157 | } 158 | 159 | return fmt.Errorf("request failed with status code %d", statusCode) 160 | } 161 | 162 | func handleGraph(body []byte, graph, query, dir string, w io.Writer) error { 163 | // TODO(saswatamcode): Update spec so that we can use client/models directly. 164 | var m struct { 165 | Data struct { 166 | ResultType string `json:"resultType"` 167 | Result json.RawMessage `json:"result"` 168 | } `json:"data"` 169 | 170 | Error string `json:"error,omitempty"` 171 | ErrorType string `json:"errorType,omitempty"` 172 | // Extra field supported by Thanos Querier. 173 | Warnings []string `json:"warnings"` 174 | } 175 | 176 | if err := json.Unmarshal(body, &m); err != nil { 177 | return fmt.Errorf("unmarshal query range response %w", err) 178 | } 179 | 180 | var matrixResult model.Matrix 181 | 182 | // Decode the Result depending on the ResultType 183 | switch m.Data.ResultType { 184 | case string(models.MetricRangeQueryResponseResultTypeMatrix): 185 | if err := json.Unmarshal(m.Data.Result, &matrixResult); err != nil { 186 | return fmt.Errorf("decode result into ValueTypeMatrix %w", err) 187 | } 188 | default: 189 | if m.Warnings != nil { 190 | return fmt.Errorf("error: %s, type: %s, warning: %s", m.Error, m.ErrorType, strings.Join(m.Warnings, ", ")) 191 | } 192 | if m.Error != "" { 193 | return fmt.Errorf("error: %s, type: %s", m.Error, m.ErrorType) 194 | } 195 | 196 | return fmt.Errorf("received status code: 200, unknown response type: '%q'", m.Data.ResultType) 197 | } 198 | 199 | // Output graph based on type specified. 200 | switch graph { 201 | case "ascii": 202 | var data [][]float64 203 | 204 | for _, ss := range matrixResult { 205 | stream := []float64{} 206 | for _, sample := range ss.Values { 207 | stream = append(stream, float64(sample.Value)) 208 | } 209 | data = append(data, stream) 210 | } 211 | 212 | // TODO(saswatamcode): Output data in some format and use standard graphing tools. 213 | fmt.Fprintln(w, asciigraph.PlotMany(data, asciigraph.Width(80))) 214 | return nil 215 | case "png": 216 | var data []chart.Series 217 | 218 | for _, ss := range matrixResult { 219 | xstream := []time.Time{} 220 | ystream := []float64{} 221 | for _, sample := range ss.Values { 222 | ystream = append(ystream, float64(sample.Value)) 223 | xstream = append(xstream, sample.Timestamp.Time()) 224 | } 225 | data = append(data, chart.TimeSeries{ 226 | Name: ss.Metric.String(), 227 | XValues: xstream, 228 | YValues: ystream, 229 | YAxis: chart.YAxisPrimary, 230 | }) 231 | } 232 | 233 | graph := chart.Chart{ 234 | XAxis: chart.XAxis{ 235 | Name: "Time", 236 | }, 237 | YAxis: chart.YAxis{ 238 | Name: "Value", 239 | }, 240 | Series: data, 241 | } 242 | 243 | f, err := os.Create(dir) 244 | if err != nil { 245 | return fmt.Errorf("could not create graph png file: %w", err) 246 | } 247 | defer f.Close() 248 | 249 | if err := graph.Render(chart.PNG, f); err != nil { 250 | return fmt.Errorf("could not render graph: %w", err) 251 | } 252 | 253 | return nil 254 | default: 255 | return fmt.Errorf("unsupported graph type: %s", graph) 256 | } 257 | } 258 | 259 | func openInBrowser(url string) error { 260 | var err error 261 | switch runtime.GOOS { 262 | case "windows": 263 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Run() 264 | case "darwin": 265 | err = exec.Command("open", url).Run() 266 | default: 267 | err = exec.Command("xdg-open", url).Run() 268 | } 269 | return err 270 | } 271 | -------------------------------------------------------------------------------- /pkg/cmd/cmd_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path" 7 | "testing" 8 | 9 | "github.com/efficientgo/tools/core/pkg/testutil" 10 | ) 11 | 12 | func TestHandleGraphs(t *testing.T) { 13 | tc := []string{"go_gc_duration_seconds", "prometheus_engine_query_duration_seconds"} 14 | wd, err := os.Getwd() 15 | testutil.Ok(t, err) 16 | dir := t.TempDir() 17 | 18 | t.Run("ascii graph", func(t *testing.T) { 19 | for _, n := range tc { 20 | out := bytes.NewBufferString("") 21 | 22 | ib, err := os.ReadFile(wd + "/testdata/" + n + ".json") 23 | testutil.Ok(t, err) 24 | 25 | testutil.Ok(t, handleGraph(ib, "ascii", n, path.Join(dir, "test.png"), out)) 26 | 27 | exp, err := os.ReadFile(wd + "/testdata/" + n + ".txt") 28 | testutil.Ok(t, err) 29 | 30 | testutil.Equals(t, exp, out.Bytes()) 31 | } 32 | }) 33 | 34 | t.Run("png graph", func(t *testing.T) { 35 | for _, n := range tc { 36 | ib, err := os.ReadFile(wd + "/testdata/" + n + ".json") 37 | testutil.Ok(t, err) 38 | 39 | testutil.Ok(t, handleGraph(ib, "png", n, path.Join(dir, "test.png"), bytes.NewBufferString(""))) 40 | 41 | exp, err := os.ReadFile(wd + "/testdata/" + n + ".png") 42 | testutil.Ok(t, err) 43 | 44 | out, err := os.ReadFile(path.Join(dir, "test.png")) 45 | testutil.Ok(t, err) 46 | 47 | testutil.Equals(t, exp, out) 48 | } 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/cmd/context.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/observatorium/obsctl/pkg/config" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func NewContextCommand(ctx context.Context) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "context", 16 | Short: "Manage context configuration.", 17 | Long: "View/Manage context configuration.", 18 | } 19 | 20 | apiCmd := &cobra.Command{ 21 | Use: "api", 22 | Short: "Add/edit/remove API configuration.", 23 | Long: "Add/edit/remove API configuration.", 24 | } 25 | 26 | var addURL, addName string 27 | apiAddCmd := &cobra.Command{ 28 | Use: "add", 29 | Short: "Add API configuration.", 30 | Long: "Add API configuration.", 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | conf, err := config.Read(logger) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | return conf.AddAPI(logger, addName, addURL) 38 | }, 39 | } 40 | 41 | apiAddCmd.Flags().StringVar(&addURL, "url", "", "The URL for the Observatorium API.") 42 | apiAddCmd.Flags().StringVar(&addName, "name", "", "Provide an optional name to easily refer to the Observatorium Instance.") 43 | 44 | err := apiAddCmd.MarkFlagRequired("url") 45 | if err != nil { 46 | panic(err) 47 | } 48 | 49 | var rmName string 50 | apiRmCmd := &cobra.Command{ 51 | Use: "rm", 52 | Short: "Remove API configuration.", 53 | Long: "Remove API configuration. If only one API is saved, that will be removed. If set to current, current will be set to nil.", 54 | RunE: func(cmd *cobra.Command, args []string) error { 55 | conf, err := config.Read(logger) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | return conf.RemoveAPI(logger, rmName) 61 | }, 62 | } 63 | 64 | apiRmCmd.Flags().StringVar(&rmName, "name", "", "The name of the Observatorium API instance to remove. No need to provide if only one API is saved.") 65 | 66 | switchCmd := &cobra.Command{ 67 | Use: "switch /", 68 | Short: "Switch to another context.", 69 | Long: "Selects a context entry.", 70 | Args: cobra.ExactArgs(1), 71 | RunE: func(cmd *cobra.Command, args []string) error { 72 | cntxt := strings.Split(args[0], "/") 73 | if len(cntxt) != 2 { 74 | return fmt.Errorf("invalid context name: use format /") 75 | } 76 | 77 | conf, err := config.Read(logger) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | return conf.SetCurrentContext(logger, cntxt[0], cntxt[1]) 83 | }, 84 | } 85 | 86 | currentCmd := &cobra.Command{ 87 | Use: "current", 88 | Short: "View current context configuration.", 89 | Long: "View current context configuration.", 90 | RunE: func(cmd *cobra.Command, args []string) error { 91 | conf, err := config.Read(logger) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | _, _, err = conf.GetCurrentContext() 97 | if err != nil { 98 | return err 99 | } 100 | 101 | // TODO(saswatamcode): Add flag to display more details. Eg -verbose 102 | fmt.Fprintf(cmd.OutOrStdout(), "The current context is: %s/%s\n", conf.Current.API, conf.Current.Tenant) 103 | return nil 104 | }, 105 | } 106 | 107 | listCmd := &cobra.Command{ 108 | Use: "list", 109 | Short: "View all context configuration.", 110 | Long: "View all context configuration.", 111 | RunE: func(cmd *cobra.Command, args []string) error { 112 | conf, err := config.Read(logger) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | for k, v := range conf.APIs { 118 | fmt.Fprintf(os.Stdout, "%s\n", string(k)) 119 | if len(v.Contexts) == 0 { 120 | fmt.Fprint(cmd.OutOrStdout(), "\t- no tenants yet, currently not usable\n") 121 | } 122 | 123 | for kc := range v.Contexts { 124 | fmt.Fprintf(cmd.OutOrStdout(), "\t- %s\n", string(kc)) 125 | } 126 | } 127 | 128 | _, _, err = conf.GetCurrentContext() 129 | if err != nil { 130 | return err 131 | } 132 | 133 | // TODO(saswatamcode): Add flag to display more details. Eg -verbose 134 | fmt.Fprintf(cmd.OutOrStdout(), "\nThe current context is: %s/%s\n", conf.Current.API, conf.Current.Tenant) 135 | return nil 136 | }, 137 | } 138 | 139 | rmCmd := &cobra.Command{ 140 | Use: "rm /", 141 | Short: "Remove context configuration.", 142 | Long: "Remove context configuration.", 143 | Args: cobra.ExactArgs(1), 144 | RunE: func(cmd *cobra.Command, args []string) error { 145 | cntxt := strings.Split(args[0], "/") 146 | if len(cntxt) != 2 { 147 | return fmt.Errorf("invalid context name: use format /") 148 | } 149 | 150 | conf, err := config.Read(logger) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | return conf.RemoveContext(logger, cntxt[0], cntxt[1]) 156 | }, 157 | } 158 | 159 | cmd.AddCommand(apiCmd) 160 | cmd.AddCommand(switchCmd) 161 | cmd.AddCommand(currentCmd) 162 | cmd.AddCommand(listCmd) 163 | cmd.AddCommand(rmCmd) 164 | 165 | apiCmd.AddCommand(apiAddCmd) 166 | apiCmd.AddCommand(apiRmCmd) 167 | 168 | return cmd 169 | } 170 | -------------------------------------------------------------------------------- /pkg/cmd/login.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/observatorium/obsctl/pkg/config" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func NewLoginCmd(ctx context.Context) *cobra.Command { 13 | tenantCfg := config.TenantConfig{OIDC: new(config.OIDCConfig)} 14 | var api, caFilePath string 15 | var disableOIDCCheck bool 16 | 17 | cmd := &cobra.Command{ 18 | Use: "login", 19 | Short: "Login as a tenant. Will also save tenant details locally.", 20 | Long: "Login as a tenant. Will also save tenant details locally.", 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | if caFilePath != "" { 23 | body, err := os.ReadFile(caFilePath) 24 | if err != nil { 25 | return err 26 | } 27 | tenantCfg.CAFile = body 28 | } 29 | conf, err := config.Read(logger) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | if _, ok := conf.APIs[api]; !ok { 35 | return fmt.Errorf("api name %s does not exist, please add it in via 'context api add'", api) 36 | } 37 | 38 | if _, err := tenantCfg.Client(ctx, logger); err != nil { 39 | return fmt.Errorf("creating authenticated client: %w", err) 40 | } 41 | 42 | return conf.AddTenant(logger, tenantCfg.Tenant, api, tenantCfg.Tenant, tenantCfg.OIDC) 43 | }, 44 | } 45 | 46 | cmd.Flags().StringVar(&tenantCfg.Tenant, "tenant", "", "The name of the tenant.") 47 | cmd.Flags().StringVar(&api, "api", "", "The name of the Observatorium API that has been saved previously.") 48 | 49 | cmd.Flags().StringVar(&caFilePath, "ca", "", "Path to the TLS CA against which to verify the Observatorium API. If no server CA is specified, the client will use the system certificates.") 50 | cmd.Flags().StringVar(&tenantCfg.OIDC.IssuerURL, "oidc.issuer-url", "", "The OIDC issuer URL, see https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery.") 51 | cmd.Flags().StringVar(&tenantCfg.OIDC.ClientSecret, "oidc.client-secret", "", "The OIDC client secret, see https://tools.ietf.org/html/rfc6749#section-2.3.") 52 | cmd.Flags().StringVar(&tenantCfg.OIDC.ClientID, "oidc.client-id", "", "The OIDC client ID, see https://tools.ietf.org/html/rfc6749#section-2.3.") 53 | cmd.Flags().StringVar(&tenantCfg.OIDC.Audience, "oidc.audience", "", "The audience for whom the access token is intended, see https://openid.net/specs/openid-connect-core-1_0.html#IDToken.") 54 | cmd.Flags().BoolVar(&tenantCfg.OIDC.OfflineAccess, "oidc.offline-access", true, "If set to false, oidc scope offline_access will not be requested, see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest") 55 | 56 | cmd.Flags().BoolVar(&disableOIDCCheck, "disable.oidc-check", false, "If set to true, OIDC flags will not be checked while saving tenant details locally.") 57 | 58 | err := cmd.MarkFlagRequired("api") 59 | if err != nil { 60 | panic(err) 61 | } 62 | 63 | err = cmd.MarkFlagRequired("tenant") 64 | if err != nil { 65 | panic(err) 66 | } 67 | 68 | return cmd 69 | } 70 | -------------------------------------------------------------------------------- /pkg/cmd/logout.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/observatorium/obsctl/pkg/config" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func NewLogoutCmd(ctx context.Context) *cobra.Command { 11 | var tenantName, apiName string 12 | cmd := &cobra.Command{ 13 | Use: "logout", 14 | Short: "Logout a tenant. Will remove locally saved details.", 15 | Long: "Logout a tenant. Will remove locally saved details.", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | conf, err := config.Read(logger) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | // If only one API is saved, we can assume tenant belongs to that API. 23 | if len(conf.APIs) == 1 { 24 | for k := range conf.APIs { 25 | return conf.RemoveTenant(logger, tenantName, k) 26 | } 27 | } 28 | 29 | return conf.RemoveTenant(logger, tenantName, apiName) 30 | }, 31 | } 32 | 33 | cmd.Flags().StringVar(&tenantName, "tenant", "", "The name of the tenant to logout.") 34 | cmd.Flags().StringVar(&apiName, "api", "", "The name of the API the tenant is associated with. Not needed in case only one API is saved locally.") 35 | 36 | err := cmd.MarkFlagRequired("tenant") 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | return cmd 42 | } 43 | -------------------------------------------------------------------------------- /pkg/cmd/logs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/observatorium/api/client" 9 | "github.com/observatorium/api/client/parameters" 10 | "github.com/observatorium/obsctl/pkg/fetcher" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func NewLogsGetCmd(ctx context.Context) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "get", 17 | Short: "Read series, labels & labels values (JSON/YAML) of a tenant.", 18 | Long: "Read series, labels & labels values (JSON/YAML) of a tenant.", 19 | } 20 | 21 | // Series command. 22 | var ( 23 | seriesMatchers []string 24 | seriesStart, seriesEnd string 25 | ) 26 | seriesCmd := &cobra.Command{ 27 | Use: "series", 28 | Short: "Get series of a tenant.", 29 | Long: "Get series of a tenant.", 30 | SilenceUsage: true, 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 33 | if err != nil { 34 | return fmt.Errorf("custom fetcher: %w", err) 35 | } 36 | 37 | params := &client.GetSeriesParams{} 38 | if len(seriesMatchers) > 0 { 39 | params.Match = seriesMatchers 40 | } 41 | if seriesStart != "" { 42 | params.Start = (*parameters.StartTS)(&seriesStart) 43 | } 44 | if seriesEnd != "" { 45 | params.End = (*parameters.EndTS)(&seriesEnd) 46 | } 47 | 48 | resp, err := f.GetSeriesWithResponse(ctx, currentTenant, params) 49 | if err != nil { 50 | return fmt.Errorf("getting response: %w", err) 51 | } 52 | 53 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 54 | }, 55 | } 56 | seriesCmd.Flags().StringArrayVarP(&seriesMatchers, "match", "m", nil, "Repeated series selector argument that selects the series to return.") 57 | seriesCmd.Flags().StringVarP(&seriesStart, "start", "s", "", "Start timestamp.") 58 | seriesCmd.Flags().StringVarP(&seriesEnd, "end", "e", "", "End timestamp.") 59 | err := seriesCmd.MarkFlagRequired("match") 60 | if err != nil { 61 | panic(err) 62 | } 63 | 64 | // Labels command. 65 | var ( 66 | labelStart, labelEnd string 67 | ) 68 | labelsCmd := &cobra.Command{ 69 | Use: "labels", 70 | Short: "Get labels of a tenant.", 71 | Long: "Get labels of a tenant.", 72 | RunE: func(cmd *cobra.Command, args []string) error { 73 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 74 | if err != nil { 75 | return fmt.Errorf("custom fetcher: %w", err) 76 | } 77 | 78 | params := &client.GetLogLabelsParams{} 79 | if labelStart != "" { 80 | params.Start = (*parameters.StartTS)(&labelStart) 81 | } 82 | if labelEnd != "" { 83 | params.End = (*parameters.EndTS)(&labelEnd) 84 | } 85 | 86 | resp, err := f.GetLogLabelsWithResponse(ctx, currentTenant, params) 87 | if err != nil { 88 | return fmt.Errorf("getting response: %w", err) 89 | } 90 | 91 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 92 | }, 93 | } 94 | 95 | labelsCmd.Flags().StringVarP(&labelStart, "start", "s", "", "Start timestamp.") 96 | labelsCmd.Flags().StringVarP(&labelEnd, "end", "e", "", "End timestamp.") 97 | 98 | // Labelvalues command. 99 | var ( 100 | labelName, labelValuesStart, labelValuesEnd string 101 | ) 102 | labelValuesCmd := &cobra.Command{ 103 | Use: "labelvalues", 104 | Short: "Get label values of a tenant.", 105 | Long: "Get label values of a tenant.", 106 | SilenceUsage: true, 107 | RunE: func(cmd *cobra.Command, args []string) error { 108 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 109 | if err != nil { 110 | return fmt.Errorf("custom fetcher: %w", err) 111 | } 112 | 113 | params := &client.GetLogLabelValuesParams{} 114 | 115 | if labelValuesStart != "" { 116 | params.Start = (*parameters.StartTS)(&labelValuesStart) 117 | } 118 | if labelValuesEnd != "" { 119 | params.End = (*parameters.EndTS)(&labelValuesEnd) 120 | } 121 | 122 | resp, err := f.GetLogLabelValuesWithResponse(ctx, currentTenant, labelName, params) 123 | if err != nil { 124 | return fmt.Errorf("getting response: %w", err) 125 | } 126 | 127 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 128 | }, 129 | } 130 | labelValuesCmd.Flags().StringVar(&labelName, "name", "", "Name of the label to fetch values for.") 131 | labelValuesCmd.Flags().StringVarP(&labelValuesStart, "start", "s", "", "Start timestamp.") 132 | labelValuesCmd.Flags().StringVarP(&labelValuesEnd, "end", "e", "", "End timestamp.") 133 | 134 | err = labelValuesCmd.MarkFlagRequired("name") 135 | if err != nil { 136 | panic(err) 137 | } 138 | 139 | // Alerts Command. 140 | alertsCmd := &cobra.Command{ 141 | Use: "alerts", 142 | Short: "Get alerts of a tenant.", 143 | Long: "Get alerts of a tenant.", 144 | SilenceUsage: true, 145 | RunE: func(cmd *cobra.Command, args []string) error { 146 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 147 | if err != nil { 148 | return fmt.Errorf("custom fetcher: %w", err) 149 | } 150 | 151 | resp, err := f.GetLogsPromAlertsWithResponse(ctx, currentTenant) 152 | if err != nil { 153 | return fmt.Errorf("getting response: %w", err) 154 | } 155 | 156 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 157 | }, 158 | } 159 | 160 | // Rules command. 161 | rulesCmd := &cobra.Command{ 162 | Use: "rules", 163 | Short: "Get rules of a tenant.", 164 | Long: "Get rules of a tenant.", 165 | SilenceUsage: true, 166 | RunE: func(cmd *cobra.Command, args []string) error { 167 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 168 | if err != nil { 169 | return fmt.Errorf("custom fetcher: %w", err) 170 | } 171 | 172 | resp, err := f.GetLogsPromRulesWithResponse(ctx, currentTenant) 173 | if err != nil { 174 | return fmt.Errorf("getting response: %w", err) 175 | } 176 | 177 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 178 | }, 179 | } 180 | 181 | // Rules.raw command. 182 | var ( 183 | rulesNamespace, rulesGroup string 184 | ) 185 | rulesRawCmd := &cobra.Command{ 186 | Use: "rules.raw", 187 | Short: "Get configured rules of a tenant.", 188 | Long: "Get configured rules of a tenant.", 189 | SilenceUsage: true, 190 | RunE: func(cmd *cobra.Command, args []string) error { 191 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 192 | if err != nil { 193 | return fmt.Errorf("custom fetcher: %w", err) 194 | } 195 | 196 | if rulesNamespace == "" { 197 | resp, err := f.GetAllLogsRulesWithResponse(ctx, currentTenant) 198 | if err != nil { 199 | return fmt.Errorf("getting response: %w", err) 200 | } 201 | 202 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 203 | } 204 | 205 | if rulesGroup != "" { 206 | resp, err := f.GetLogsRulesGroupWithResponse( 207 | ctx, currentTenant, parameters.LogRulesNamespace(rulesNamespace), parameters.LogRulesGroup(rulesGroup), 208 | ) 209 | if err != nil { 210 | return fmt.Errorf("getting response: %w", err) 211 | } 212 | 213 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 214 | } 215 | 216 | resp, err := f.GetLogsRulesWithResponse(ctx, currentTenant, parameters.LogRulesNamespace(rulesNamespace)) 217 | if err != nil { 218 | return fmt.Errorf("getting response: %w", err) 219 | } 220 | 221 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 222 | }, 223 | } 224 | rulesRawCmd.Flags().StringVarP(&rulesNamespace, "namespace", "n", "", "Rules Namespace") 225 | rulesRawCmd.Flags().StringVarP(&rulesGroup, "group", "g", "", "Rules Group in a namespace") 226 | 227 | cmd.AddCommand(seriesCmd) 228 | cmd.AddCommand(labelsCmd) 229 | cmd.AddCommand(labelValuesCmd) 230 | cmd.AddCommand(alertsCmd) 231 | cmd.AddCommand(rulesCmd) 232 | cmd.AddCommand(rulesRawCmd) 233 | 234 | return cmd 235 | } 236 | 237 | func NewLogsSetCmd(ctx context.Context) *cobra.Command { 238 | var ( 239 | rulesNamespace, ruleFilePath string 240 | ) 241 | cmd := &cobra.Command{ 242 | Use: "set", 243 | Short: "Write Loki Rules configuration for a tenant.", 244 | Long: "Write Loki Rules configuration for a tenant.", 245 | SilenceUsage: true, 246 | RunE: func(cmd *cobra.Command, args []string) error { 247 | file, err := os.Open(ruleFilePath) 248 | if err != nil { 249 | return fmt.Errorf("opening rule file: %w", err) 250 | } 251 | defer file.Close() 252 | 253 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 254 | if err != nil { 255 | return fmt.Errorf("custom fetcher: %w", err) 256 | } 257 | 258 | resp, err := f.SetLogsRulesWithBodyWithResponse(ctx, currentTenant, parameters.LogRulesNamespace(rulesNamespace), "application/yaml", file) 259 | if err != nil { 260 | return fmt.Errorf("getting response: %w", err) 261 | } 262 | 263 | if resp.StatusCode()/100 != 2 { 264 | if len(resp.Body) != 0 { 265 | fmt.Fprintln(cmd.OutOrStdout(), string(resp.Body)) 266 | return fmt.Errorf("request failed with status code %d", resp.StatusCode()) 267 | } 268 | } 269 | 270 | fmt.Fprintln(cmd.OutOrStdout(), string(resp.Body)) 271 | return nil 272 | }, 273 | } 274 | 275 | cmd.Flags().StringVarP(&rulesNamespace, "namespace", "n", "", "Rules Namespace") 276 | cmd.Flags().StringVar(&ruleFilePath, "rule.file", "", "Path to Rules configuration file, which will be set for a tenant.") 277 | 278 | err := cmd.MarkFlagRequired("rule.file") 279 | if err != nil { 280 | panic(err) 281 | } 282 | 283 | err = cmd.MarkFlagRequired("namespace") 284 | if err != nil { 285 | panic(err) 286 | } 287 | 288 | return cmd 289 | } 290 | 291 | func NewLogsQueryCmd(ctx context.Context) *cobra.Command { 292 | var ( 293 | isRange bool 294 | time, start, end, direction, step, interval string 295 | limit float32 296 | ) 297 | cmd := &cobra.Command{ 298 | Use: "query", 299 | Short: "Query logs for a tenant.", 300 | Long: "Query logs for a tenant. Can get results for both instant and range queries. Pass a single valid LogQl query to fetch results for.", 301 | Example: `obsctl logs query "prometheus_http_request_total"`, 302 | Args: cobra.ExactArgs(1), 303 | SilenceUsage: true, 304 | RunE: func(cmd *cobra.Command, args []string) error { 305 | if args[0] == "" { 306 | return fmt.Errorf("no query provided") 307 | } 308 | 309 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 310 | if err != nil { 311 | return fmt.Errorf("custom fetcher: %w", err) 312 | } 313 | 314 | query := parameters.LogqlQuery(args[0]) 315 | 316 | if isRange { 317 | params := &client.GetLogRangeQueryParams{Query: &query} 318 | if limit != 0 { 319 | params.Limit = (*parameters.Limit)(&limit) 320 | } 321 | 322 | if start == "" || end == "" { 323 | return fmt.Errorf("start/end timestamp not provided for range query") 324 | } 325 | 326 | params.Start = (*parameters.StartTS)(&start) 327 | params.End = (*parameters.EndTS)(&end) 328 | 329 | if step != "" { 330 | params.Step = &step 331 | } 332 | 333 | if interval != "" { 334 | params.Interval = &interval 335 | } 336 | 337 | if direction != "" { 338 | params.Direction = &direction 339 | } 340 | 341 | resp, err := f.GetLogRangeQueryWithResponse(ctx, currentTenant, params) 342 | if err != nil { 343 | return fmt.Errorf("getting response: %w", err) 344 | } 345 | 346 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 347 | } else { 348 | params := &client.GetLogInstantQueryParams{Query: &query} 349 | if time != "" { 350 | params.Time = &time 351 | } 352 | 353 | if limit != 0 { 354 | params.Limit = (*parameters.Limit)(&limit) 355 | } 356 | 357 | if direction != "" { 358 | params.Direction = &direction 359 | } 360 | 361 | resp, err := f.GetLogInstantQueryWithResponse(ctx, currentTenant, params) 362 | if err != nil { 363 | return fmt.Errorf("getting response: %w", err) 364 | } 365 | 366 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 367 | } 368 | }, 369 | } 370 | 371 | // Flags for instant query. 372 | cmd.Flags().StringVar(&time, "time", "", "Evaluation timestamp. Only used if --range is false.") 373 | 374 | // Flags for range query. 375 | cmd.Flags().BoolVar(&isRange, "range", false, "If true, query will be evaluated as a range query. See https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries.") 376 | cmd.Flags().StringVarP(&start, "start", "s", "", "Start timestamp. Must be provided if --range is true.") 377 | cmd.Flags().StringVarP(&end, "end", "e", "", "End timestamp. Must be provided if --range is true.") 378 | cmd.Flags().StringVar(&step, "step", "", "Query resolution step width. Only used if --range is provided.") 379 | cmd.Flags().StringVar(&interval, "interval", "", "return entries at (or greater than) the specified interval,Only used if --range is provided.") 380 | 381 | // // Common flags. 382 | cmd.Flags().Float32Var(&limit, "limit", 100, "The max number of entries to return. Only used if --range is false.") 383 | cmd.Flags().StringVar(&direction, "direction", "", "Determines the sort order of logs.. Only used if --range is false.") 384 | 385 | return cmd 386 | } 387 | 388 | func NewLogsCmd(ctx context.Context) *cobra.Command { 389 | cmd := &cobra.Command{ 390 | Use: "logs", 391 | Short: "logs based operations for Observatorium.", 392 | Long: "logs based operations for Observatorium.", 393 | } 394 | 395 | cmd.AddCommand(NewLogsGetCmd(ctx)) 396 | cmd.AddCommand(NewLogsSetCmd(ctx)) 397 | cmd.AddCommand(NewLogsQueryCmd(ctx)) 398 | 399 | return cmd 400 | } 401 | -------------------------------------------------------------------------------- /pkg/cmd/metrics.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "path" 9 | "time" 10 | 11 | "github.com/go-kit/log/level" 12 | "github.com/observatorium/api/client" 13 | "github.com/observatorium/api/client/parameters" 14 | "github.com/observatorium/obsctl/pkg/fetcher" 15 | "github.com/observatorium/obsctl/pkg/proxy" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | func NewMetricsGetCmd(ctx context.Context) *cobra.Command { 20 | cmd := &cobra.Command{ 21 | Use: "get", 22 | Short: "Read series, labels & rules (JSON/YAML) of a tenant.", 23 | Long: "Read series, labels & rules (JSON/YAML) of a tenant.", 24 | } 25 | 26 | // Series command. 27 | var ( 28 | seriesMatchers []string 29 | seriesStart, seriesEnd string 30 | ) 31 | seriesCmd := &cobra.Command{ 32 | Use: "series", 33 | Short: "Get series of a tenant.", 34 | Long: "Get series of a tenant.", 35 | SilenceUsage: true, 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 38 | if err != nil { 39 | return fmt.Errorf("custom fetcher: %w", err) 40 | } 41 | 42 | params := &client.GetSeriesParams{} 43 | if len(seriesMatchers) > 0 { 44 | params.Match = seriesMatchers 45 | } 46 | if seriesStart != "" { 47 | params.Start = (*parameters.StartTS)(&seriesStart) 48 | } 49 | if seriesEnd != "" { 50 | params.End = (*parameters.EndTS)(&seriesEnd) 51 | } 52 | 53 | resp, err := f.GetSeriesWithResponse(ctx, currentTenant, params) 54 | if err != nil { 55 | return fmt.Errorf("getting response: %w", err) 56 | } 57 | 58 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 59 | }, 60 | } 61 | seriesCmd.Flags().StringArrayVarP(&seriesMatchers, "match", "m", nil, "Repeated series selector argument that selects the series to return.") 62 | seriesCmd.Flags().StringVarP(&seriesStart, "start", "s", "", "Start timestamp.") 63 | seriesCmd.Flags().StringVarP(&seriesEnd, "end", "e", "", "End timestamp.") 64 | err := seriesCmd.MarkFlagRequired("match") 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | // Labels command. 70 | var ( 71 | labelMatchers []string 72 | labelStart, labelEnd string 73 | ) 74 | labelsCmd := &cobra.Command{ 75 | Use: "labels", 76 | Short: "Get labels of a tenant.", 77 | Long: "Get labels of a tenant.", 78 | RunE: func(cmd *cobra.Command, args []string) error { 79 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 80 | if err != nil { 81 | return fmt.Errorf("custom fetcher: %w", err) 82 | } 83 | 84 | params := &client.GetLabelsParams{} 85 | if len(labelMatchers) > 0 { 86 | params.Match = (*parameters.OptionalSeriesMatcher)(&labelMatchers) 87 | } 88 | if labelStart != "" { 89 | params.Start = (*parameters.StartTS)(&labelStart) 90 | } 91 | if labelEnd != "" { 92 | params.End = (*parameters.EndTS)(&labelEnd) 93 | } 94 | 95 | resp, err := f.GetLabelsWithResponse(ctx, currentTenant, params) 96 | if err != nil { 97 | return fmt.Errorf("getting response: %w", err) 98 | } 99 | 100 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 101 | }, 102 | } 103 | labelsCmd.Flags().StringArrayVarP(&labelMatchers, "match", "m", []string{}, "Repeated series selector argument that selects the series from which to read the label names.") 104 | labelsCmd.Flags().StringVarP(&labelStart, "start", "s", "", "Start timestamp.") 105 | labelsCmd.Flags().StringVarP(&labelEnd, "end", "e", "", "End timestamp.") 106 | 107 | // Labelvalues command. 108 | var ( 109 | labelValuesMatchers []string 110 | labelName, labelValuesStart, labelValuesEnd string 111 | ) 112 | labelValuesCmd := &cobra.Command{ 113 | Use: "labelvalues", 114 | Short: "Get label values of a tenant.", 115 | Long: "Get label values of a tenant.", 116 | SilenceUsage: true, 117 | RunE: func(cmd *cobra.Command, args []string) error { 118 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 119 | if err != nil { 120 | return fmt.Errorf("custom fetcher: %w", err) 121 | } 122 | 123 | params := &client.GetLabelValuesParams{} 124 | if len(labelValuesMatchers) > 0 { 125 | params.Match = (*parameters.OptionalSeriesMatcher)(&labelValuesMatchers) 126 | } 127 | if labelValuesStart != "" { 128 | params.Start = (*parameters.StartTS)(&labelValuesStart) 129 | } 130 | if labelValuesEnd != "" { 131 | params.End = (*parameters.EndTS)(&labelValuesEnd) 132 | } 133 | 134 | resp, err := f.GetLabelValuesWithResponse(ctx, currentTenant, labelName, params) 135 | if err != nil { 136 | return fmt.Errorf("getting response: %w", err) 137 | } 138 | 139 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 140 | }, 141 | } 142 | labelValuesCmd.Flags().StringVar(&labelName, "name", "", "Name of the label to fetch values for.") 143 | labelValuesCmd.Flags().StringArrayVarP(&labelValuesMatchers, "match", "m", []string{}, "Repeated series selector argument that selects the series from which to read the label values.") 144 | labelValuesCmd.Flags().StringVarP(&labelValuesStart, "start", "s", "", "Start timestamp.") 145 | labelValuesCmd.Flags().StringVarP(&labelValuesEnd, "end", "e", "", "End timestamp.") 146 | 147 | err = labelValuesCmd.MarkFlagRequired("name") 148 | if err != nil { 149 | panic(err) 150 | } 151 | 152 | // Rules command. 153 | var ( 154 | ruleMatchers []string 155 | ruleType string 156 | ) 157 | rulesCmd := &cobra.Command{ 158 | Use: "rules", 159 | Short: "Get rules of a tenant.", 160 | Long: "Get rules of a tenant.", 161 | SilenceUsage: true, 162 | RunE: func(cmd *cobra.Command, args []string) error { 163 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 164 | if err != nil { 165 | return fmt.Errorf("custom fetcher: %w", err) 166 | } 167 | 168 | params := &client.GetRulesParams{} 169 | if len(ruleMatchers) > 0 { 170 | params.Match = &ruleMatchers 171 | } 172 | if ruleType != "" { 173 | if ruleType != "alert" && ruleType != "record" { 174 | return fmt.Errorf("not valid rule type") 175 | } 176 | params.Type = &ruleType 177 | } 178 | 179 | resp, err := f.GetRulesWithResponse(ctx, currentTenant, params) 180 | if err != nil { 181 | return fmt.Errorf("getting response: %w", err) 182 | } 183 | 184 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 185 | }, 186 | } 187 | rulesCmd.Flags().StringArrayVarP(&ruleMatchers, "match", "m", []string{}, "Repeated series selector argument that selects the series from which to read the label values.") 188 | rulesCmd.Flags().StringVarP(&ruleType, "type", "t", "", "Rule type to filter by i.e, alert or record. No filtering done if skipped.") 189 | 190 | // Rules raw command. 191 | rulesRawCmd := &cobra.Command{ 192 | Use: "rules.raw", 193 | Short: "Get configured rules of a tenant.", 194 | Long: "Get configured rules of a tenant.", 195 | SilenceUsage: true, 196 | RunE: func(cmd *cobra.Command, args []string) error { 197 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 198 | if err != nil { 199 | return fmt.Errorf("custom fetcher: %w", err) 200 | } 201 | 202 | resp, err := f.GetRawRulesWithResponse(ctx, currentTenant) 203 | if err != nil { 204 | return fmt.Errorf("getting response: %w", err) 205 | } 206 | 207 | if resp.StatusCode()/100 != 2 { 208 | if len(resp.Body) != 0 { 209 | fmt.Fprintln(cmd.OutOrStdout(), string(resp.Body)) 210 | return fmt.Errorf("request failed with status code %d", resp.StatusCode()) 211 | } 212 | } 213 | 214 | fmt.Fprintln(cmd.OutOrStdout(), string(resp.Body)) 215 | return nil 216 | }, 217 | } 218 | 219 | cmd.AddCommand(seriesCmd) 220 | cmd.AddCommand(labelsCmd) 221 | cmd.AddCommand(labelValuesCmd) 222 | cmd.AddCommand(rulesCmd) 223 | cmd.AddCommand(rulesRawCmd) 224 | 225 | return cmd 226 | } 227 | 228 | func NewMetricsSetCmd(ctx context.Context) *cobra.Command { 229 | var ruleFilePath string 230 | cmd := &cobra.Command{ 231 | Use: "set", 232 | Short: "Write Prometheus Rules configuration for a tenant.", 233 | Long: "Write Prometheus Rules configuration for a tenant.", 234 | SilenceUsage: true, 235 | RunE: func(cmd *cobra.Command, args []string) error { 236 | file, err := os.Open(ruleFilePath) 237 | if err != nil { 238 | return fmt.Errorf("opening rule file: %w", err) 239 | } 240 | defer file.Close() 241 | 242 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 243 | if err != nil { 244 | return fmt.Errorf("custom fetcher: %w", err) 245 | } 246 | 247 | resp, err := f.SetRawRulesWithBodyWithResponse(ctx, currentTenant, "application/yaml", file) 248 | if err != nil { 249 | return fmt.Errorf("getting response: %w", err) 250 | } 251 | 252 | if resp.StatusCode()/100 != 2 { 253 | if len(resp.Body) != 0 { 254 | fmt.Fprintln(cmd.OutOrStdout(), string(resp.Body)) 255 | return fmt.Errorf("request failed with status code %d", resp.StatusCode()) 256 | } 257 | } 258 | 259 | fmt.Fprintln(cmd.OutOrStdout(), string(resp.Body)) 260 | return nil 261 | }, 262 | } 263 | 264 | cmd.Flags().StringVar(&ruleFilePath, "rule.file", "", "Path to Rules configuration file, which will be set for a tenant.") 265 | err := cmd.MarkFlagRequired("rule.file") 266 | if err != nil { 267 | panic(err) 268 | } 269 | 270 | return cmd 271 | } 272 | 273 | func NewMetricsQueryCmd(ctx context.Context) *cobra.Command { 274 | var ( 275 | isRange bool 276 | evalTime, timeout, start, end, step, graph string 277 | ) 278 | cmd := &cobra.Command{ 279 | Use: "query", 280 | Short: "Query metrics for a tenant.", 281 | Long: "Query metrics for a tenant. Can get results for both instant and range queries. Pass a single valid PromQL query to fetch results for.", 282 | Example: `obsctl metrics query "prometheus_http_request_total"`, 283 | Args: cobra.ExactArgs(1), 284 | SilenceUsage: true, 285 | RunE: func(cmd *cobra.Command, args []string) error { 286 | if args[0] == "" { 287 | return fmt.Errorf("no query provided") 288 | } 289 | 290 | f, currentTenant, err := fetcher.NewCustomFetcher(ctx, logger) 291 | if err != nil { 292 | return fmt.Errorf("custom fetcher: %w", err) 293 | } 294 | 295 | query := parameters.PromqlQuery(args[0]) 296 | 297 | if isRange { 298 | params := &client.GetRangeQueryParams{Query: &query} 299 | if timeout != "" { 300 | params.Timeout = (*parameters.QueryTimeout)(&timeout) 301 | } 302 | 303 | if start == "" || end == "" { 304 | return fmt.Errorf("start/end timestamp not provided for range query") 305 | } 306 | 307 | params.Start = (*parameters.StartTS)(&start) 308 | params.End = (*parameters.EndTS)(&end) 309 | 310 | if step != "" { 311 | params.Step = &step 312 | } 313 | 314 | resp, err := f.GetRangeQueryWithResponse(ctx, currentTenant, params) 315 | if err != nil { 316 | return fmt.Errorf("getting response: %w", err) 317 | } 318 | 319 | if graph != "" { 320 | wd, err := os.Getwd() 321 | if err != nil { 322 | return fmt.Errorf("could not get working dir: %w", err) 323 | } 324 | 325 | return handleGraph(resp.Body, graph, string(query), path.Join(wd, "graph"+time.Now().String()+".png"), cmd.OutOrStdout()) 326 | } 327 | 328 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 329 | } else { 330 | params := &client.GetInstantQueryParams{Query: &query} 331 | if evalTime != "" { 332 | params.Time = &evalTime 333 | } 334 | if timeout != "" { 335 | params.Timeout = (*parameters.QueryTimeout)(&timeout) 336 | } 337 | 338 | resp, err := f.GetInstantQueryWithResponse(ctx, currentTenant, params) 339 | if err != nil { 340 | return fmt.Errorf("getting response: %w", err) 341 | } 342 | 343 | return handleResponse(resp.Body, resp.HTTPResponse.Header.Get("content-type"), resp.StatusCode(), cmd) 344 | } 345 | }, 346 | } 347 | 348 | // Flags for instant query. 349 | cmd.Flags().StringVar(&evalTime, "time", "", "Evaluation timestamp. Only used if --range is false.") 350 | 351 | // Flags for range query. 352 | cmd.Flags().BoolVar(&isRange, "range", false, "If true, query will be evaluated as a range query. See https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries.") 353 | cmd.Flags().StringVarP(&start, "start", "s", "", "Start timestamp. Must be provided if --range is true.") 354 | cmd.Flags().StringVarP(&end, "end", "e", "", "End timestamp. Must be provided if --range is true.") 355 | cmd.Flags().StringVar(&step, "step", "", "Query resolution step width. Only used if --range is provided.") 356 | cmd.Flags().StringVar(&graph, "graph", "", "If specified, range query result will output an (ascii|png) graph.") 357 | 358 | // Common flags. 359 | cmd.Flags().StringVar(&timeout, "timeout", "", "Evaluation timeout. Optional.") 360 | 361 | return cmd 362 | } 363 | 364 | func NewMetricsUICmd(ctx context.Context) *cobra.Command { 365 | var listen string 366 | cmd := &cobra.Command{ 367 | Use: "ui", 368 | Short: "Starts a proxy server and opens a Thanos Query UI for making requests to Observatorium API as a tenant.", 369 | Long: `Starts a proxy server and opens a Thanos Query UI for making requests to Observatorium API as a tenant. 370 | Note that all request URLs will have /api/metrics/v1/ prefixed in their paths.`, 371 | SilenceUsage: true, 372 | RunE: func(cmd *cobra.Command, args []string) error { 373 | // Run a server as we would in main. 374 | s, err := proxy.NewProxyServer(ctx, logger, "metrics", listen) 375 | if err != nil { 376 | return err 377 | } 378 | 379 | go func() { 380 | level.Info(logger).Log("msg", "starting ui proxy server", "addr", listen) 381 | 382 | if err = s.ListenAndServe(); err != nil && err != http.ErrServerClosed { 383 | level.Error(logger).Log("msg", "failed to start proxy server", "error", err) 384 | } 385 | }() 386 | 387 | // Open Querier UI in browser. 388 | if err := openInBrowser("http://localhost" + listen); err != nil { 389 | return err 390 | } 391 | 392 | <-ctx.Done() 393 | return s.Shutdown(context.Background()) 394 | }, 395 | } 396 | 397 | cmd.Flags().StringVar(&listen, "listen", ":8080", "Address for proxy server to listen on.") 398 | 399 | return cmd 400 | } 401 | 402 | func NewMetricsCmd(ctx context.Context) *cobra.Command { 403 | cmd := &cobra.Command{ 404 | Use: "metrics", 405 | Short: "Metrics based operations for Observatorium.", 406 | Long: "Metrics based operations for Observatorium.", 407 | } 408 | 409 | cmd.AddCommand(NewMetricsGetCmd(ctx)) 410 | cmd.AddCommand(NewMetricsSetCmd(ctx)) 411 | cmd.AddCommand(NewMetricsQueryCmd(ctx)) 412 | cmd.AddCommand(NewMetricsUICmd(ctx)) 413 | 414 | return cmd 415 | } 416 | -------------------------------------------------------------------------------- /pkg/cmd/testdata/go_gc_duration_seconds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/obsctl/751b92bac586604482d120fc1de839c881bf410e/pkg/cmd/testdata/go_gc_duration_seconds.png -------------------------------------------------------------------------------- /pkg/cmd/testdata/go_gc_duration_seconds.txt: -------------------------------------------------------------------------------- 1 | 0.0045 ┼─────────────────────────────────────────────────────────────────────────────── 2 | 0.0034 ┼─────────────────────────────────────────────────────────────────────────────── 3 | 0.0023 ┤ 4 | 0.0011 ┤╭───────────╮ ╭──────────╮ ╭ 5 | 0.0000 ┼─────────────────────────────────────────────────────────────────────────────── 6 | -------------------------------------------------------------------------------- /pkg/cmd/testdata/prometheus_engine_query_duration_seconds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/observatorium/obsctl/751b92bac586604482d120fc1de839c881bf410e/pkg/cmd/testdata/prometheus_engine_query_duration_seconds.png -------------------------------------------------------------------------------- /pkg/cmd/testdata/prometheus_engine_query_duration_seconds.txt: -------------------------------------------------------------------------------- 1 | 0.0038 ┤ ╭────╮ ╭──────────╮╭─────╮ ╭────────────────────╮ ╭───────────────── 2 | 0.0025 ┼─────────╯ ╰─╯ ╰╯ ╰───╯ ╭╮╭──╮ ╭╮ ╰─╯ 3 | 0.0013 ┼──────────────────────────────────────────────╯╰╯ ╰─╯╰──────────────────────── 4 | 0.0000 ┼─────────────────────────────────────────────────────────────────────────────── 5 | -------------------------------------------------------------------------------- /pkg/cmd/traces.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/url" 9 | 10 | "github.com/go-kit/log/level" 11 | "github.com/observatorium/obsctl/pkg/config" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func NewTraceServicesCmd(ctx context.Context) *cobra.Command { 16 | var outputFormat string 17 | cmd := &cobra.Command{ 18 | Use: "services", 19 | Short: "List names of services", 20 | Long: "List names of services with trace information", 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | // Don't print CLI flag usage if we get network error 23 | cmd.SilenceUsage = true 24 | 25 | cfg, err := config.Read(logger) 26 | if err != nil { 27 | return fmt.Errorf("getting reading config: %w", err) 28 | } 29 | 30 | client, err := cfg.Client(ctx, logger) 31 | if err != nil { 32 | return fmt.Errorf("getting current client: %w", err) 33 | } 34 | 35 | level.Debug(logger).Log( 36 | "msg", "Using configuration", 37 | "URL", cfg.APIs[cfg.Current.API].URL, 38 | "tenant", cfg.Current.Tenant) 39 | 40 | svcUrl, err := url.Parse(cfg.APIs[cfg.Current.API].URL) 41 | if err != nil { 42 | return fmt.Errorf("parsing url: %w", err) 43 | } 44 | svcUrl.Path = "api/traces/v1/rhobs/api/services" 45 | resp, err := client.Get(svcUrl.String()) 46 | if err != nil { 47 | return fmt.Errorf("getting: %w", err) 48 | } 49 | bodyBytes, err := io.ReadAll(resp.Body) 50 | if err != nil { 51 | return fmt.Errorf("getting: %w", err) 52 | } 53 | if resp.StatusCode >= 300 { 54 | level.Debug(logger).Log( 55 | "msg", "/api/services request failed", 56 | "statusCode", resp.StatusCode, 57 | "status", resp.Status, 58 | "body", string(bodyBytes)) 59 | return fmt.Errorf("%d: %s", resp.StatusCode, resp.Status) 60 | } 61 | 62 | switch outputFormat { 63 | case "table": 64 | svcs, err := services(bodyBytes) 65 | if err != nil { 66 | return fmt.Errorf("parsing services: %w", err) 67 | } 68 | if len(svcs) == 0 { 69 | fmt.Fprintln(cmd.OutOrStderr(), "No services found") 70 | return nil 71 | } 72 | fmt.Fprintln(cmd.OutOrStderr(), "SERVICE") 73 | for _, svc := range svcs { 74 | fmt.Fprintln(cmd.OutOrStdout(), svc) 75 | } 76 | case "json": 77 | json, err := prettyPrintJSON(bodyBytes) 78 | if err != nil { 79 | return fmt.Errorf("failed to pretty print JSON: %s", err) 80 | } 81 | fmt.Fprintln(cmd.OutOrStdout(), json) 82 | default: 83 | cmd.SilenceUsage = false 84 | return fmt.Errorf("unknown format %s", outputFormat) 85 | } 86 | return nil 87 | }, 88 | } 89 | 90 | cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. One of: json|table") 91 | 92 | return cmd 93 | } 94 | 95 | func NewTracesCmd(ctx context.Context) *cobra.Command { 96 | cmd := &cobra.Command{ 97 | Use: "traces", 98 | Short: "Trace-based operations for Observatorium.", 99 | Long: "Trace-based operations for Observatorium.", 100 | } 101 | 102 | cmd.AddCommand(NewTraceServicesCmd(ctx)) 103 | 104 | return cmd 105 | } 106 | 107 | // services convert the internal Jaeger API /api/services response 108 | // into a list of services 109 | func services(js []byte) ([]string, error) { 110 | var result map[string]interface{} 111 | err := json.Unmarshal(js, &result) 112 | if err != nil { 113 | return nil, err 114 | } 115 | data, ok := result["data"] 116 | if !ok { 117 | return nil, fmt.Errorf("no JSON data in %s", string(js)) 118 | } 119 | if data == nil { 120 | return []string{}, nil 121 | } 122 | services, ok := data.([]interface{}) 123 | if !ok { 124 | return nil, fmt.Errorf("expected JSON list in %s", string(js)) 125 | } 126 | retval := make([]string, len(services)) 127 | for i, svc := range services { 128 | retval[i] = fmt.Sprintf("%s", svc) 129 | } 130 | return retval, nil 131 | } 132 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | "strings" 14 | "time" 15 | 16 | "github.com/coreos/go-oidc/v3/oidc" 17 | "github.com/go-kit/log" 18 | "github.com/go-kit/log/level" 19 | "golang.org/x/oauth2" 20 | "golang.org/x/oauth2/clientcredentials" 21 | ) 22 | 23 | const ( 24 | configFileName = "config.json" 25 | configDirName = "obsctl" 26 | envVar = "OBSCTL_CONFIG_PATH" 27 | ) 28 | 29 | // getConfigFilePath returns the obsctl config file path or the value of env variable. 30 | // Useful for testing. 31 | func getConfigFilePath() string { 32 | override := os.Getenv(envVar) 33 | if len(override) != 0 { 34 | return override 35 | } 36 | 37 | usrConfigDir, err := os.UserConfigDir() 38 | if err != nil { 39 | return configFileName 40 | } 41 | 42 | return filepath.Join(usrConfigDir, configDirName, configFileName) 43 | } 44 | 45 | func ensureConfigDir() error { 46 | if err := os.MkdirAll(path.Dir(getConfigFilePath()), 0700); err != nil { 47 | return fmt.Errorf("creating config directory: %w", err) 48 | } 49 | 50 | return nil 51 | } 52 | 53 | // Config represents the structure of the configuration file. 54 | type Config struct { 55 | pathOverride string 56 | 57 | APIs map[string]APIConfig `json:"apis"` 58 | Current struct { 59 | API string `json:"api"` 60 | Tenant string `json:"tenant"` 61 | } `json:"current"` 62 | } 63 | 64 | // APIConfig represents configuration for an instance of Observatorium. 65 | type APIConfig struct { 66 | URL string `json:"url"` 67 | Contexts map[string]TenantConfig `json:"contexts"` 68 | } 69 | 70 | // TenantConfig represents configuration for a tenant. 71 | type TenantConfig struct { 72 | Tenant string `json:"tenant"` 73 | CAFile []byte `json:"ca"` 74 | OIDC *OIDCConfig `json:"oidc"` 75 | } 76 | 77 | // OIDCConfig represents OIDC auth config for a tenant. 78 | type OIDCConfig struct { 79 | Token *oauth2.Token `json:"token"` 80 | 81 | Audience string `json:"audience"` 82 | ClientID string `json:"clientID"` 83 | ClientSecret string `json:"clientSecret"` 84 | IssuerURL string `json:"issuerURL"` 85 | OfflineAccess bool `json:"offlineAccess"` 86 | } 87 | 88 | // Client returns a OAuth2 HTTP client based on the configuration for a tenant. 89 | func (t *TenantConfig) Client(ctx context.Context, logger log.Logger) (*http.Client, error) { 90 | if t.OIDC != nil { 91 | provider, err := oidc.NewProvider(ctx, t.OIDC.IssuerURL) 92 | if err != nil { 93 | return nil, fmt.Errorf("constructing oidc provider: %w", err) 94 | } 95 | 96 | scopes := []string{"openid"} 97 | 98 | if t.OIDC.OfflineAccess { 99 | scopes = append(scopes, "offline_access") 100 | } 101 | 102 | ccc := clientcredentials.Config{ 103 | ClientID: t.OIDC.ClientID, 104 | ClientSecret: t.OIDC.ClientSecret, 105 | TokenURL: provider.Endpoint().TokenURL, 106 | Scopes: scopes, 107 | } 108 | 109 | if t.OIDC.Audience != "" { 110 | ccc.EndpointParams = url.Values{ 111 | "audience": []string{t.OIDC.Audience}, 112 | } 113 | } 114 | 115 | ts := ccc.TokenSource(ctx) 116 | 117 | // If token has not expired, we can reuse. 118 | if t.OIDC.Token != nil { 119 | currentTime := time.Now() 120 | if t.OIDC.Token.Expiry.After(currentTime) { 121 | ts = oauth2.ReuseTokenSource(t.OIDC.Token, ts) 122 | } 123 | } 124 | 125 | tkn, err := ts.Token() 126 | if err != nil { 127 | return nil, fmt.Errorf("fetching token: %w", err) 128 | } 129 | 130 | t.OIDC.Token = tkn 131 | 132 | level.Debug(logger).Log("msg", "fetched token", "tenant", t.Tenant) 133 | 134 | return oauth2.NewClient(ctx, ts), nil 135 | } 136 | 137 | return http.DefaultClient, nil 138 | } 139 | 140 | // Tenant returns a OAuth2 HTTP transport based on the configuration for a tenant. 141 | func (t *TenantConfig) Transport(ctx context.Context, logger log.Logger) (http.RoundTripper, error) { 142 | if t.OIDC != nil { 143 | provider, err := oidc.NewProvider(ctx, t.OIDC.IssuerURL) 144 | if err != nil { 145 | return nil, fmt.Errorf("constructing oidc provider: %w", err) 146 | } 147 | 148 | scopes := []string{"openid"} 149 | 150 | if t.OIDC.OfflineAccess { 151 | scopes = append(scopes, "offline_access") 152 | } 153 | 154 | ccc := clientcredentials.Config{ 155 | ClientID: t.OIDC.ClientID, 156 | ClientSecret: t.OIDC.ClientSecret, 157 | TokenURL: provider.Endpoint().TokenURL, 158 | Scopes: scopes, 159 | } 160 | 161 | if t.OIDC.Audience != "" { 162 | ccc.EndpointParams = url.Values{ 163 | "audience": []string{t.OIDC.Audience}, 164 | } 165 | } 166 | 167 | ts := ccc.TokenSource(ctx) 168 | 169 | // If token has not expired, we can reuse. 170 | if t.OIDC.Token != nil { 171 | currentTime := time.Now() 172 | if t.OIDC.Token.Expiry.After(currentTime) { 173 | ts = oauth2.ReuseTokenSource(t.OIDC.Token, ts) 174 | } 175 | } 176 | 177 | tkn, err := ts.Token() 178 | if err != nil { 179 | return nil, fmt.Errorf("fetching token: %w", err) 180 | } 181 | 182 | t.OIDC.Token = tkn 183 | 184 | level.Debug(logger).Log("msg", "fetched token", "tenant", t.Tenant) 185 | 186 | return &oauth2.Transport{ 187 | Source: ts, 188 | Base: http.DefaultTransport, 189 | }, nil 190 | } 191 | 192 | return http.DefaultTransport, nil 193 | } 194 | 195 | // Client returns an OAuth2 HTTP client based on the current context configuration. 196 | func (c *Config) Client(ctx context.Context, logger log.Logger) (*http.Client, error) { 197 | tenant, _, err := c.GetCurrentContext() 198 | if err != nil { 199 | return nil, fmt.Errorf("getting current context: %w", err) 200 | } 201 | 202 | client, err := tenant.Client(ctx, logger) 203 | if err != nil { 204 | return nil, err 205 | } 206 | 207 | c.APIs[c.Current.API].Contexts[c.Current.Tenant] = tenant 208 | if err := c.Save(logger); err != nil { 209 | return nil, fmt.Errorf("updating token in config file: %w", err) 210 | } 211 | 212 | level.Debug(logger).Log("msg", "updated token in config file", "tenant", tenant.Tenant) 213 | 214 | return client, nil 215 | } 216 | 217 | // Transport returns an OAuth2 HTTP transport based on the current context configuration. 218 | func (c *Config) Transport(ctx context.Context, logger log.Logger) (http.RoundTripper, error) { 219 | tenant, _, err := c.GetCurrentContext() 220 | if err != nil { 221 | return nil, fmt.Errorf("getting current context: %w", err) 222 | } 223 | 224 | transport, err := tenant.Transport(ctx, logger) 225 | if err != nil { 226 | return nil, err 227 | } 228 | 229 | c.APIs[c.Current.API].Contexts[c.Current.Tenant] = tenant 230 | if err := c.Save(logger); err != nil { 231 | return nil, fmt.Errorf("updating token in config file: %w", err) 232 | } 233 | 234 | level.Debug(logger).Log("msg", "updated token in config file", "tenant", tenant.Tenant) 235 | 236 | return transport, nil 237 | } 238 | 239 | // Read loads configuration from disk. 240 | func Read(logger log.Logger) (*Config, error) { 241 | if err := ensureConfigDir(); err != nil { 242 | return nil, err 243 | } 244 | 245 | file, err := os.OpenFile(getConfigFilePath(), os.O_RDONLY|os.O_CREATE, 0600) 246 | if err != nil { 247 | return nil, fmt.Errorf("opening config file: %w", err) 248 | } 249 | defer file.Close() 250 | 251 | cfg := Config{pathOverride: getConfigFilePath()} 252 | 253 | if err := json.NewDecoder(file).Decode(&cfg); err != nil && err != io.EOF { 254 | return nil, fmt.Errorf("parsing config file: %w", err) 255 | } 256 | 257 | level.Debug(logger).Log("msg", "read and parsed config file") 258 | 259 | return &cfg, nil 260 | } 261 | 262 | // Save writes current config to the disk. 263 | func (c *Config) Save(logger log.Logger) error { 264 | if err := ensureConfigDir(); err != nil { 265 | return err 266 | } 267 | 268 | file, err := os.OpenFile(getConfigFilePath(), os.O_RDWR|os.O_TRUNC|os.O_CREATE, 0600) 269 | if err != nil { 270 | return fmt.Errorf("opening config file: %w", err) 271 | } 272 | defer file.Close() 273 | 274 | encoder := json.NewEncoder(file) 275 | encoder.SetIndent("", " ") 276 | if err := encoder.Encode(c); err != nil { 277 | return fmt.Errorf("writing config: %w", err) 278 | } 279 | 280 | level.Debug(logger).Log("msg", "saved config in config file") 281 | 282 | return nil 283 | } 284 | 285 | // AddAPI adds a new Observatorium API to the configuration and saves the config to disk. 286 | // In case no name is provided, the hostname of the API URL is used instead. 287 | func (c *Config) AddAPI(logger log.Logger, name string, apiURL string) error { 288 | if c.APIs == nil { 289 | c.APIs = make(map[string]APIConfig) 290 | level.Debug(logger).Log("msg", "initialize config API map") 291 | } 292 | 293 | url, err := url.Parse(apiURL) 294 | if err != nil { 295 | return fmt.Errorf("%s is not a valid URL", url) 296 | } 297 | 298 | // url.Parse might pass a URL with only path, so need to check here for scheme and host. 299 | // As per docs: https://pkg.go.dev/net/url#Parse. 300 | if url.Host == "" || url.Scheme == "" { 301 | return fmt.Errorf("%s is not a valid URL (scheme: %s,host: %s)", url, url.Scheme, url.Host) 302 | } 303 | 304 | if name == "" { 305 | // Host name cannot contain slashes, so need not check. 306 | name = url.Host 307 | level.Debug(logger).Log("msg", "use hostname as name") 308 | } else { 309 | // Need to check due to semantics of context switch. 310 | if strings.Contains(string(name), "/") { 311 | return fmt.Errorf("api name %s cannot contain slashes", name) 312 | } 313 | } 314 | 315 | if _, ok := c.APIs[name]; ok { 316 | return fmt.Errorf("api with name %s already exists", name) 317 | } 318 | 319 | // Add trailing slash if not present. 320 | parsedUrl := url.String() 321 | if parsedUrl[len(parsedUrl)-1:] != "/" { 322 | parsedUrl += "/" 323 | } 324 | 325 | c.APIs[name] = APIConfig{URL: parsedUrl} 326 | 327 | return c.Save(logger) 328 | } 329 | 330 | // RemoveAPI removes a locally saved Observatorium API config as well as its tenants. 331 | // If the current context is pointing to the API being removed, the context is emptied. 332 | func (c *Config) RemoveAPI(logger log.Logger, name string) error { 333 | if len(c.APIs) == 1 { 334 | // Only one API was saved, so can assume it was current context. 335 | c.APIs = map[string]APIConfig{} 336 | c.Current.API = "" 337 | c.Current.Tenant = "" 338 | level.Debug(logger).Log("msg", "emptied current and removed single config") 339 | return c.Save(logger) 340 | } 341 | 342 | if _, ok := c.APIs[name]; !ok { 343 | return fmt.Errorf("api with name %s doesn't exist", name) 344 | } 345 | 346 | if c.Current.API == name { 347 | c.Current.API = "" 348 | c.Current.Tenant = "" 349 | level.Debug(logger).Log("msg", "empty current config") 350 | } 351 | 352 | delete(c.APIs, name) 353 | 354 | return c.Save(logger) 355 | } 356 | 357 | // AddTenant adds configuration for a tenant under an API and saves it to disk. 358 | // Also, sets new tenant to current in case current config is empty. 359 | func (c *Config) AddTenant(logger log.Logger, name string, api string, tenant string, oidcCfg *OIDCConfig) error { 360 | if _, ok := c.APIs[api]; !ok { 361 | return fmt.Errorf("api with name %s doesn't exist", api) 362 | } 363 | 364 | if c.APIs[api].Contexts == nil { 365 | a := c.APIs[api] 366 | a.Contexts = make(map[string]TenantConfig) 367 | 368 | c.APIs[api] = a 369 | } 370 | 371 | // Need to check due to semantics of context switch. 372 | if strings.Contains(string(name), "/") { 373 | return fmt.Errorf("tenant name %s cannot contain slashes", name) 374 | } 375 | 376 | if _, ok := c.APIs[api].Contexts[name]; ok { 377 | return fmt.Errorf("tenant with name %s already exists in api %s", name, api) 378 | } 379 | 380 | c.APIs[api].Contexts[name] = TenantConfig{ 381 | Tenant: tenant, 382 | OIDC: oidcCfg, 383 | } 384 | 385 | // If the current context is empty, set the newly added tenant as current. 386 | if c.Current.API == "" && c.Current.Tenant == "" { 387 | c.Current.API = api 388 | c.Current.Tenant = name 389 | level.Debug(logger).Log("msg", "set new tenant as current") 390 | } 391 | 392 | return c.Save(logger) 393 | } 394 | 395 | // RemoveTenant removes configuration of a tenant under an API and saves changes to disk. 396 | func (c *Config) RemoveTenant(logger log.Logger, name string, api string) error { 397 | if _, ok := c.APIs[api]; !ok { 398 | return fmt.Errorf("api with name %s doesn't exist", api) 399 | } 400 | 401 | if _, ok := c.APIs[api].Contexts[name]; !ok { 402 | return fmt.Errorf("tenant with name %s doesn't exist in api %s", name, api) 403 | } 404 | 405 | delete(c.APIs[api].Contexts, name) 406 | 407 | return c.Save(logger) 408 | } 409 | 410 | func (c *Config) GetContext(api string, tenant string) (TenantConfig, APIConfig, error) { 411 | if _, ok := c.APIs[api]; !ok { 412 | return TenantConfig{}, APIConfig{}, fmt.Errorf("api with name %s doesn't exist", c.Current.API) 413 | } 414 | 415 | if _, ok := c.APIs[api].Contexts[tenant]; !ok { 416 | return TenantConfig{}, APIConfig{}, fmt.Errorf("tenant with name %s doesn't exist in api %s", c.Current.Tenant, c.Current.API) 417 | } 418 | 419 | return c.APIs[api].Contexts[tenant], c.APIs[api], nil 420 | } 421 | 422 | // GetCurrentContext returns the currently set context i.e, the current API and tenant configuration. 423 | func (c *Config) GetCurrentContext() (TenantConfig, APIConfig, error) { 424 | if c.Current.API == "" || c.Current.Tenant == "" { 425 | return TenantConfig{}, APIConfig{}, fmt.Errorf("current context is empty") 426 | } 427 | 428 | return c.GetContext(c.Current.API, c.Current.Tenant) 429 | } 430 | 431 | // SetCurrentContext switches the current context to given api and tenant. 432 | func (c *Config) SetCurrentContext(logger log.Logger, api string, tenant string) error { 433 | if _, ok := c.APIs[api]; !ok { 434 | return fmt.Errorf("api with name %s doesn't exist", api) 435 | } 436 | 437 | if _, ok := c.APIs[api].Contexts[tenant]; !ok { 438 | return fmt.Errorf("tenant with name %s doesn't exist in api %s", tenant, api) 439 | } 440 | 441 | if c.Current.API == api && c.Current.Tenant == tenant { 442 | level.Debug(logger).Log("msg", "context is the same as current") 443 | } 444 | 445 | c.Current.API = api 446 | c.Current.Tenant = tenant 447 | 448 | return c.Save(logger) 449 | } 450 | 451 | // RemoveContext removes the specified context /. If the API configuration has only one tenant, 452 | // the API configuration is removed. 453 | func (c *Config) RemoveContext(logger log.Logger, api string, tenant string) error { 454 | // If there is only one tenant per API configuration, remove the whole API configuration. 455 | if _, ok := c.APIs[api].Contexts[tenant]; ok { 456 | if len(c.APIs[api].Contexts) == 1 { 457 | return c.RemoveAPI(logger, api) 458 | } 459 | } 460 | 461 | return c.RemoveTenant(logger, tenant, api) 462 | } 463 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/efficientgo/tools/core/pkg/testutil" 11 | "github.com/go-kit/log" 12 | "github.com/go-kit/log/level" 13 | ) 14 | 15 | func TestSave(t *testing.T) { 16 | tmpDir := t.TempDir() 17 | t.Cleanup(func() { testutil.Ok(t, os.RemoveAll(tmpDir)) }) 18 | testutil.Ok(t, os.MkdirAll(filepath.Join(tmpDir, "obsctl", "test"), os.ModePerm)) 19 | testutil.Ok(t, os.WriteFile(filepath.Join(tmpDir, "obsctl", "test", "config.json"), []byte(""), os.ModePerm)) 20 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(tmpDir, "obsctl", "test", "config.json"))) 21 | 22 | tlogger := level.NewFilter(log.NewJSONLogger(log.NewSyncWriter(os.Stderr)), level.AllowDebug()) 23 | 24 | t.Run("empty config check", func(t *testing.T) { 25 | cfg := Config{ 26 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 27 | } 28 | 29 | testutil.Ok(t, cfg.Save(tlogger)) 30 | 31 | b, err := os.ReadFile(filepath.Join(tmpDir, "obsctl", "test", "config.json")) 32 | testutil.Ok(t, err) 33 | 34 | var cfgExp Config 35 | testutil.Ok(t, json.Unmarshal(b, &cfgExp)) 36 | 37 | testutil.Equals(t, cfg.APIs, cfgExp.APIs) 38 | testutil.Equals(t, cfg.Current, cfgExp.Current) 39 | }) 40 | 41 | t.Run("config with one API no tenant", func(t *testing.T) { 42 | cfg := Config{ 43 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 44 | APIs: map[string]APIConfig{ 45 | "stage": {URL: "https://stage.api:9090", Contexts: nil}, 46 | }, 47 | } 48 | 49 | testutil.Ok(t, cfg.Save(tlogger)) 50 | 51 | b, err := os.ReadFile(filepath.Join(tmpDir, "obsctl", "test", "config.json")) 52 | testutil.Ok(t, err) 53 | 54 | var cfgExp Config 55 | testutil.Ok(t, json.Unmarshal(b, &cfgExp)) 56 | 57 | testutil.Equals(t, cfg.APIs, cfgExp.APIs) 58 | testutil.Equals(t, cfg.Current, cfgExp.Current) 59 | }) 60 | 61 | t.Run("config with one API and tenant", func(t *testing.T) { 62 | cfg := Config{ 63 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 64 | APIs: map[string]APIConfig{ 65 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 66 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 67 | }}, 68 | }, 69 | } 70 | 71 | testutil.Ok(t, cfg.Save(tlogger)) 72 | 73 | b, err := os.ReadFile(filepath.Join(tmpDir, "obsctl", "test", "config.json")) 74 | testutil.Ok(t, err) 75 | 76 | var cfgExp Config 77 | testutil.Ok(t, json.Unmarshal(b, &cfgExp)) 78 | 79 | testutil.Equals(t, cfg.APIs, cfgExp.APIs) 80 | testutil.Equals(t, cfg.Current, cfgExp.Current) 81 | }) 82 | 83 | t.Run("config with multiple API and tenants", func(t *testing.T) { 84 | cfg := Config{ 85 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 86 | APIs: map[string]APIConfig{ 87 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 88 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 89 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 90 | }}, 91 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 92 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 93 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 94 | }}, 95 | }, 96 | } 97 | 98 | testutil.Ok(t, cfg.Save(tlogger)) 99 | 100 | b, err := os.ReadFile(filepath.Join(tmpDir, "obsctl", "test", "config.json")) 101 | testutil.Ok(t, err) 102 | 103 | var cfgExp Config 104 | testutil.Ok(t, json.Unmarshal(b, &cfgExp)) 105 | 106 | testutil.Equals(t, cfg.APIs, cfgExp.APIs) 107 | testutil.Equals(t, cfg.Current, cfgExp.Current) 108 | }) 109 | } 110 | 111 | func TestRead(t *testing.T) { 112 | tmpDir := t.TempDir() 113 | t.Cleanup(func() { testutil.Ok(t, os.RemoveAll(tmpDir)) }) 114 | testutil.Ok(t, os.MkdirAll(filepath.Join(tmpDir, "obsctl", "test"), os.ModePerm)) 115 | testutil.Ok(t, os.WriteFile(filepath.Join(tmpDir, "obsctl", "test", "config.json"), []byte(""), os.ModePerm)) 116 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(tmpDir, "obsctl", "test", "config.json"))) 117 | 118 | tlogger := level.NewFilter(log.NewJSONLogger(log.NewSyncWriter(os.Stderr)), level.AllowDebug()) 119 | 120 | t.Run("empty config check", func(t *testing.T) { 121 | cfg := Config{ 122 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 123 | } 124 | 125 | testutil.Ok(t, cfg.Save(tlogger)) 126 | 127 | cfgExp, err := Read(tlogger) 128 | testutil.Ok(t, err) 129 | 130 | testutil.Equals(t, cfg, *cfgExp) 131 | }) 132 | 133 | t.Run("config with one API no tenant", func(t *testing.T) { 134 | cfg := Config{ 135 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 136 | APIs: map[string]APIConfig{ 137 | "stage": {URL: "https://stage.api:9090", Contexts: nil}, 138 | }, 139 | } 140 | 141 | testutil.Ok(t, cfg.Save(tlogger)) 142 | 143 | cfgExp, err := Read(tlogger) 144 | testutil.Ok(t, err) 145 | 146 | testutil.Equals(t, cfg, *cfgExp) 147 | }) 148 | 149 | t.Run("config with one API and tenant", func(t *testing.T) { 150 | cfg := Config{ 151 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 152 | APIs: map[string]APIConfig{ 153 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 154 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 155 | }}, 156 | }, 157 | } 158 | 159 | testutil.Ok(t, cfg.Save(tlogger)) 160 | 161 | cfgExp, err := Read(tlogger) 162 | testutil.Ok(t, err) 163 | 164 | testutil.Equals(t, cfg, *cfgExp) 165 | }) 166 | 167 | t.Run("config with multiple API and tenants", func(t *testing.T) { 168 | cfg := Config{ 169 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 170 | APIs: map[string]APIConfig{ 171 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 172 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 173 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 174 | }}, 175 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 176 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 177 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 178 | }}, 179 | }, 180 | } 181 | 182 | testutil.Ok(t, cfg.Save(tlogger)) 183 | 184 | cfgExp, err := Read(tlogger) 185 | testutil.Ok(t, err) 186 | 187 | testutil.Equals(t, cfg, *cfgExp) 188 | }) 189 | } 190 | 191 | func TestAddAPI(t *testing.T) { 192 | tmpDir := t.TempDir() 193 | t.Cleanup(func() { testutil.Ok(t, os.RemoveAll(tmpDir)) }) 194 | testutil.Ok(t, os.MkdirAll(filepath.Join(tmpDir, "obsctl", "test"), os.ModePerm)) 195 | testutil.Ok(t, os.WriteFile(filepath.Join(tmpDir, "obsctl", "test", "config.json"), []byte(""), os.ModePerm)) 196 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(tmpDir, "obsctl", "test", "config.json"))) 197 | 198 | tlogger := level.NewFilter(log.NewJSONLogger(log.NewSyncWriter(os.Stderr)), level.AllowDebug()) 199 | 200 | t.Run("first or empty config", func(t *testing.T) { 201 | cfg := Config{ 202 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 203 | } 204 | 205 | testutil.Ok(t, cfg.AddAPI(tlogger, "stage", "http://stage.obs.api/")) 206 | 207 | exp := map[string]APIConfig{"stage": {URL: "http://stage.obs.api/", Contexts: nil}} 208 | 209 | testutil.Equals(t, cfg.APIs, exp) 210 | }) 211 | 212 | t.Run("config with one API no tenant", func(t *testing.T) { 213 | cfg := Config{ 214 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 215 | APIs: map[string]APIConfig{ 216 | "stage": {URL: "https://stage.api:9090/", Contexts: nil}, 217 | }, 218 | } 219 | 220 | testutil.Ok(t, cfg.AddAPI(tlogger, "prod", "https://prod.api:8080/")) 221 | 222 | exp := map[string]APIConfig{ 223 | "stage": {URL: "https://stage.api:9090/", Contexts: nil}, 224 | "prod": {URL: "https://prod.api:8080/", Contexts: nil}, 225 | } 226 | 227 | testutil.Equals(t, cfg.APIs, exp) 228 | }) 229 | 230 | t.Run("config with one API and tenant", func(t *testing.T) { 231 | cfg := Config{ 232 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 233 | APIs: map[string]APIConfig{ 234 | "stage": {URL: "https://stage.api:9090/", Contexts: map[string]TenantConfig{ 235 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 236 | }}, 237 | }, 238 | } 239 | 240 | testutil.Ok(t, cfg.AddAPI(tlogger, "prod", "https://prod.api:8080/")) 241 | 242 | exp := map[string]APIConfig{ 243 | "stage": {URL: "https://stage.api:9090/", Contexts: map[string]TenantConfig{ 244 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 245 | }}, 246 | "prod": {URL: "https://prod.api:8080/", Contexts: nil}, 247 | } 248 | 249 | testutil.Equals(t, cfg.APIs, exp) 250 | }) 251 | 252 | t.Run("config with multiple API and tenants", func(t *testing.T) { 253 | cfg := Config{ 254 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 255 | APIs: map[string]APIConfig{ 256 | "stage": {URL: "https://stage.api:9090/", Contexts: map[string]TenantConfig{ 257 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 258 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 259 | }}, 260 | "prod": {URL: "https://prod.api:9090/", Contexts: map[string]TenantConfig{ 261 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 262 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 263 | }}, 264 | }, 265 | } 266 | 267 | testutil.Ok(t, cfg.AddAPI(tlogger, "test", "https://test.api:8080")) 268 | 269 | exp := map[string]APIConfig{ 270 | "stage": {URL: "https://stage.api:9090/", Contexts: map[string]TenantConfig{ 271 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 272 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 273 | }}, 274 | "prod": {URL: "https://prod.api:9090/", Contexts: map[string]TenantConfig{ 275 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 276 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 277 | }}, 278 | "test": {URL: "https://test.api:8080/", Contexts: nil}, 279 | } 280 | 281 | testutil.Equals(t, cfg.APIs, exp) 282 | }) 283 | 284 | t.Run("api with no name", func(t *testing.T) { 285 | cfg := Config{ 286 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 287 | APIs: map[string]APIConfig{ 288 | "stage": {URL: "https://stage.api:9090/", Contexts: map[string]TenantConfig{ 289 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 290 | }}, 291 | }, 292 | } 293 | 294 | testutil.Ok(t, cfg.AddAPI(tlogger, "", "https://prod.api:8080/")) 295 | 296 | exp := map[string]APIConfig{ 297 | "stage": {URL: "https://stage.api:9090/", Contexts: map[string]TenantConfig{ 298 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 299 | }}, 300 | "prod.api:8080": {URL: "https://prod.api:8080/", Contexts: nil}, 301 | } 302 | 303 | testutil.Equals(t, cfg.APIs, exp) 304 | }) 305 | 306 | t.Run("api with no name and invalid url", func(t *testing.T) { 307 | cfg := Config{ 308 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 309 | APIs: map[string]APIConfig{ 310 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 311 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 312 | }}, 313 | }, 314 | } 315 | 316 | err := cfg.AddAPI(tlogger, "", "abcdefghijk") 317 | testutil.NotOk(t, err) 318 | 319 | testutil.Equals(t, fmt.Errorf("abcdefghijk is not a valid URL (scheme: ,host: )"), err) 320 | }) 321 | 322 | t.Run("api with no trailing slash", func(t *testing.T) { 323 | cfg := Config{ 324 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 325 | APIs: map[string]APIConfig{ 326 | "stage": {URL: "https://stage.api:9090/", Contexts: map[string]TenantConfig{ 327 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 328 | }}, 329 | }, 330 | } 331 | 332 | testutil.Ok(t, cfg.AddAPI(tlogger, "", "https://prod.api:8080")) 333 | 334 | exp := map[string]APIConfig{ 335 | "stage": {URL: "https://stage.api:9090/", Contexts: map[string]TenantConfig{ 336 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 337 | }}, 338 | "prod.api:8080": {URL: "https://prod.api:8080/", Contexts: nil}, 339 | } 340 | 341 | testutil.Equals(t, cfg.APIs, exp) 342 | }) 343 | 344 | t.Run("api with slash in name", func(t *testing.T) { 345 | cfg := Config{ 346 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 347 | APIs: map[string]APIConfig{ 348 | "stage": {URL: "https://stage.api:9090/", Contexts: map[string]TenantConfig{ 349 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 350 | }}, 351 | }, 352 | } 353 | 354 | err := cfg.AddAPI(tlogger, "prod/123", "https://prod.api:8080") 355 | testutil.NotOk(t, err) 356 | 357 | testutil.Equals(t, fmt.Errorf("api name prod/123 cannot contain slashes"), err) 358 | }) 359 | } 360 | 361 | func TestRemoveAPI(t *testing.T) { 362 | tmpDir := t.TempDir() 363 | t.Cleanup(func() { testutil.Ok(t, os.RemoveAll(tmpDir)) }) 364 | testutil.Ok(t, os.MkdirAll(filepath.Join(tmpDir, "obsctl", "test"), os.ModePerm)) 365 | testutil.Ok(t, os.WriteFile(filepath.Join(tmpDir, "obsctl", "test", "config.json"), []byte(""), os.ModePerm)) 366 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(tmpDir, "obsctl", "test", "config.json"))) 367 | 368 | tlogger := level.NewFilter(log.NewJSONLogger(log.NewSyncWriter(os.Stderr)), level.AllowDebug()) 369 | 370 | t.Run("empty config", func(t *testing.T) { 371 | cfg := Config{ 372 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 373 | } 374 | 375 | err := cfg.RemoveAPI(tlogger, "stage") 376 | testutil.NotOk(t, err) 377 | testutil.Equals(t, fmt.Errorf("api with name stage doesn't exist"), err) 378 | }) 379 | 380 | t.Run("config with one API no tenant", func(t *testing.T) { 381 | cfg := Config{ 382 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 383 | APIs: map[string]APIConfig{ 384 | "stage": {URL: "https://stage.api:9090", Contexts: nil}, 385 | }, 386 | } 387 | 388 | testutil.Ok(t, cfg.RemoveAPI(tlogger, "stage")) 389 | testutil.Equals(t, cfg.APIs, map[string]APIConfig{}) 390 | }) 391 | 392 | t.Run("config with one API and no name given", func(t *testing.T) { 393 | cfg := Config{ 394 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 395 | APIs: map[string]APIConfig{ 396 | "stage": {URL: "https://stage.api:9090", Contexts: nil}, 397 | }, 398 | } 399 | 400 | testutil.Ok(t, cfg.RemoveAPI(tlogger, "")) 401 | testutil.Equals(t, cfg.APIs, map[string]APIConfig{}) 402 | }) 403 | 404 | t.Run("config with one API and tenant", func(t *testing.T) { 405 | cfg := Config{ 406 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 407 | APIs: map[string]APIConfig{ 408 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 409 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 410 | }}, 411 | }, 412 | } 413 | 414 | testutil.Ok(t, cfg.RemoveAPI(tlogger, "stage")) 415 | testutil.Equals(t, cfg.APIs, map[string]APIConfig{}) 416 | }) 417 | 418 | t.Run("config with one API and tenant but no name given", func(t *testing.T) { 419 | cfg := Config{ 420 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 421 | APIs: map[string]APIConfig{ 422 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 423 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 424 | }}, 425 | }, 426 | } 427 | 428 | testutil.Ok(t, cfg.RemoveAPI(tlogger, "")) 429 | testutil.Equals(t, cfg.APIs, map[string]APIConfig{}) 430 | }) 431 | 432 | t.Run("config with one current API and tenant but no name given", func(t *testing.T) { 433 | cfg := Config{ 434 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 435 | APIs: map[string]APIConfig{ 436 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 437 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 438 | }}, 439 | }, 440 | Current: struct { 441 | API string `json:"api"` 442 | Tenant string `json:"tenant"` 443 | }{ 444 | API: "stage", 445 | Tenant: "first", 446 | }, 447 | } 448 | 449 | testutil.Ok(t, cfg.RemoveAPI(tlogger, "")) 450 | testutil.Equals(t, cfg.APIs, map[string]APIConfig{}) 451 | }) 452 | 453 | t.Run("config with multiple API and tenants", func(t *testing.T) { 454 | cfg := Config{ 455 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 456 | APIs: map[string]APIConfig{ 457 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 458 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 459 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 460 | }}, 461 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 462 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 463 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 464 | }}, 465 | }, 466 | } 467 | 468 | testutil.Ok(t, cfg.RemoveAPI(tlogger, "stage")) 469 | 470 | exp := map[string]APIConfig{ 471 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 472 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 473 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 474 | }}, 475 | } 476 | 477 | testutil.Equals(t, cfg.APIs, exp) 478 | }) 479 | 480 | t.Run("config with multiple API and tenants and no name given", func(t *testing.T) { 481 | cfg := Config{ 482 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 483 | APIs: map[string]APIConfig{ 484 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 485 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 486 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 487 | }}, 488 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 489 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 490 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 491 | }}, 492 | }, 493 | } 494 | 495 | err := cfg.RemoveAPI(tlogger, "") 496 | testutil.NotOk(t, err) 497 | 498 | testutil.Equals(t, fmt.Errorf("api with name doesn't exist"), err) 499 | }) 500 | 501 | t.Run("config with multiple API and tenants", func(t *testing.T) { 502 | cfg := Config{ 503 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 504 | APIs: map[string]APIConfig{ 505 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 506 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 507 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 508 | }}, 509 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 510 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 511 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 512 | }}, 513 | }, 514 | } 515 | 516 | testutil.Ok(t, cfg.RemoveAPI(tlogger, "stage")) 517 | 518 | exp := map[string]APIConfig{ 519 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 520 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 521 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 522 | }}, 523 | } 524 | 525 | testutil.Equals(t, cfg.APIs, exp) 526 | }) 527 | 528 | t.Run("config with multiple API and current", func(t *testing.T) { 529 | cfg := Config{ 530 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 531 | APIs: map[string]APIConfig{ 532 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 533 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 534 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 535 | }}, 536 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 537 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 538 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 539 | }}, 540 | }, 541 | Current: struct { 542 | API string `json:"api"` 543 | Tenant string `json:"tenant"` 544 | }{ 545 | API: "stage", 546 | Tenant: "first", 547 | }, 548 | } 549 | 550 | testutil.Ok(t, cfg.RemoveAPI(tlogger, "stage")) 551 | 552 | exp := map[string]APIConfig{ 553 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 554 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 555 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 556 | }}, 557 | } 558 | 559 | testutil.Equals(t, cfg.APIs, exp) 560 | testutil.Equals(t, cfg.Current, struct { 561 | API string `json:"api"` 562 | Tenant string `json:"tenant"` 563 | }{ 564 | API: "", 565 | Tenant: "", 566 | }) 567 | }) 568 | } 569 | 570 | func TestAddTenant(t *testing.T) { 571 | tmpDir := t.TempDir() 572 | t.Cleanup(func() { testutil.Ok(t, os.RemoveAll(tmpDir)) }) 573 | testutil.Ok(t, os.MkdirAll(filepath.Join(tmpDir, "obsctl", "test"), os.ModePerm)) 574 | testutil.Ok(t, os.WriteFile(filepath.Join(tmpDir, "obsctl", "test", "config.json"), []byte(""), os.ModePerm)) 575 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(tmpDir, "obsctl", "test", "config.json"))) 576 | 577 | tlogger := level.NewFilter(log.NewJSONLogger(log.NewSyncWriter(os.Stderr)), level.AllowDebug()) 578 | 579 | testoidc := &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"} 580 | 581 | t.Run("config with one API no tenant", func(t *testing.T) { 582 | cfg := Config{ 583 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 584 | APIs: map[string]APIConfig{ 585 | "stage": {URL: "https://stage.api:9090", Contexts: nil}, 586 | }, 587 | } 588 | 589 | testutil.Ok(t, cfg.AddTenant(tlogger, "first", "stage", "first", testoidc)) 590 | 591 | exp := map[string]APIConfig{ 592 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 593 | "first": {Tenant: "first", OIDC: testoidc}, 594 | }}, 595 | } 596 | 597 | testutil.Equals(t, cfg.APIs, exp) 598 | testutil.Equals(t, cfg.Current, struct { 599 | API string `json:"api"` 600 | Tenant string `json:"tenant"` 601 | }{ 602 | API: "stage", 603 | Tenant: "first", 604 | }) 605 | 606 | }) 607 | 608 | t.Run("config with one API and tenant", func(t *testing.T) { 609 | cfg := Config{ 610 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 611 | APIs: map[string]APIConfig{ 612 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 613 | "first": {Tenant: "first", OIDC: testoidc}, 614 | }}, 615 | }, 616 | } 617 | 618 | testutil.Ok(t, cfg.AddTenant(tlogger, "second", "stage", "second", testoidc)) 619 | 620 | exp := map[string]APIConfig{ 621 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 622 | "first": {Tenant: "first", OIDC: testoidc}, 623 | "second": {Tenant: "second", OIDC: testoidc}, 624 | }}, 625 | } 626 | 627 | testutil.Equals(t, cfg.APIs, exp) 628 | testutil.Equals(t, cfg.Current, struct { 629 | API string `json:"api"` 630 | Tenant string `json:"tenant"` 631 | }{ 632 | API: "stage", 633 | Tenant: "second", 634 | }) 635 | }) 636 | 637 | t.Run("tenant already exists", func(t *testing.T) { 638 | cfg := Config{ 639 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 640 | APIs: map[string]APIConfig{ 641 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 642 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 643 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 644 | }}, 645 | }, 646 | } 647 | 648 | err := cfg.AddTenant(tlogger, "second", "stage", "second", testoidc) 649 | testutil.NotOk(t, err) 650 | 651 | testutil.Equals(t, fmt.Errorf("tenant with name second already exists in api stage"), err) 652 | }) 653 | 654 | t.Run("no such api", func(t *testing.T) { 655 | cfg := Config{ 656 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 657 | APIs: map[string]APIConfig{ 658 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 659 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 660 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 661 | }}, 662 | }, 663 | } 664 | 665 | err := cfg.AddTenant(tlogger, "second", "prod", "second", testoidc) 666 | testutil.NotOk(t, err) 667 | 668 | testutil.Equals(t, fmt.Errorf("api with name prod doesn't exist"), err) 669 | }) 670 | 671 | t.Run("tenant name has slash", func(t *testing.T) { 672 | cfg := Config{ 673 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 674 | APIs: map[string]APIConfig{ 675 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 676 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 677 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 678 | }}, 679 | }, 680 | } 681 | 682 | err := cfg.AddTenant(tlogger, "test/123", "stage", "test/123", testoidc) 683 | testutil.NotOk(t, err) 684 | 685 | testutil.Equals(t, fmt.Errorf("tenant name test/123 cannot contain slashes"), err) 686 | }) 687 | } 688 | 689 | func TestRemoveTenant(t *testing.T) { 690 | tmpDir := t.TempDir() 691 | t.Cleanup(func() { testutil.Ok(t, os.RemoveAll(tmpDir)) }) 692 | testutil.Ok(t, os.MkdirAll(filepath.Join(tmpDir, "obsctl", "test"), os.ModePerm)) 693 | testutil.Ok(t, os.WriteFile(filepath.Join(tmpDir, "obsctl", "test", "config.json"), []byte(""), os.ModePerm)) 694 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(tmpDir, "obsctl", "test", "config.json"))) 695 | 696 | tlogger := level.NewFilter(log.NewJSONLogger(log.NewSyncWriter(os.Stderr)), level.AllowDebug()) 697 | 698 | t.Run("empty config", func(t *testing.T) { 699 | cfg := Config{ 700 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 701 | } 702 | 703 | err := cfg.RemoveTenant(tlogger, "first", "stage") 704 | testutil.NotOk(t, err) 705 | testutil.Equals(t, fmt.Errorf("api with name stage doesn't exist"), err) 706 | }) 707 | 708 | t.Run("config with one API no tenant", func(t *testing.T) { 709 | cfg := Config{ 710 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 711 | APIs: map[string]APIConfig{ 712 | "stage": {URL: "https://stage.api:9090", Contexts: nil}, 713 | }, 714 | } 715 | 716 | err := cfg.RemoveTenant(tlogger, "first", "stage") 717 | 718 | testutil.NotOk(t, err) 719 | testutil.Equals(t, fmt.Errorf("tenant with name first doesn't exist in api stage"), err) 720 | }) 721 | 722 | t.Run("config with one API and tenant", func(t *testing.T) { 723 | cfg := Config{ 724 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 725 | APIs: map[string]APIConfig{ 726 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 727 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 728 | }}, 729 | }, 730 | } 731 | 732 | testutil.Ok(t, cfg.RemoveTenant(tlogger, "first", "stage")) 733 | 734 | testutil.Equals(t, cfg.APIs, map[string]APIConfig{"stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{}}}) 735 | }) 736 | 737 | t.Run("config with multiple API and tenants", func(t *testing.T) { 738 | cfg := Config{ 739 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 740 | APIs: map[string]APIConfig{ 741 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 742 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 743 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 744 | }}, 745 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 746 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 747 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 748 | }}, 749 | }, 750 | } 751 | 752 | testutil.Ok(t, cfg.RemoveTenant(tlogger, "second", "stage")) 753 | testutil.Ok(t, cfg.RemoveTenant(tlogger, "first", "prod")) 754 | 755 | exp := map[string]APIConfig{ 756 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 757 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 758 | }}, 759 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 760 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 761 | }}, 762 | } 763 | 764 | testutil.Equals(t, cfg.APIs, exp) 765 | }) 766 | } 767 | 768 | func TestGetCurrentContext(t *testing.T) { 769 | tmpDir := t.TempDir() 770 | t.Cleanup(func() { testutil.Ok(t, os.RemoveAll(tmpDir)) }) 771 | testutil.Ok(t, os.MkdirAll(filepath.Join(tmpDir, "obsctl", "test"), os.ModePerm)) 772 | testutil.Ok(t, os.WriteFile(filepath.Join(tmpDir, "obsctl", "test", "config.json"), []byte(""), os.ModePerm)) 773 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(tmpDir, "obsctl", "test", "config.json"))) 774 | 775 | t.Run("empty config", func(t *testing.T) { 776 | cfg := Config{ 777 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 778 | } 779 | 780 | _, _, err := cfg.GetCurrentContext() 781 | testutil.NotOk(t, err) 782 | testutil.Equals(t, fmt.Errorf("current context is empty"), err) 783 | }) 784 | 785 | t.Run("config with multiple API and current", func(t *testing.T) { 786 | cfg := Config{ 787 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 788 | APIs: map[string]APIConfig{ 789 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 790 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 791 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 792 | }}, 793 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 794 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 795 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 796 | }}, 797 | }, 798 | Current: struct { 799 | API string `json:"api"` 800 | Tenant string `json:"tenant"` 801 | }{ 802 | API: "stage", 803 | Tenant: "second", 804 | }, 805 | } 806 | 807 | tenantConfig, apiConfig, err := cfg.GetCurrentContext() 808 | testutil.Ok(t, err) 809 | 810 | tenantExp := TenantConfig{Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}} 811 | 812 | apiExp := APIConfig{URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 813 | "first": {Tenant: "first", CAFile: nil, OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 814 | "second": {Tenant: "second", CAFile: nil, OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 815 | }} 816 | 817 | testutil.Equals(t, tenantConfig, tenantExp) 818 | testutil.Equals(t, apiConfig, apiExp) 819 | }) 820 | } 821 | 822 | func TestSetCurrentContext(t *testing.T) { 823 | tmpDir := t.TempDir() 824 | t.Cleanup(func() { testutil.Ok(t, os.RemoveAll(tmpDir)) }) 825 | testutil.Ok(t, os.MkdirAll(filepath.Join(tmpDir, "obsctl", "test"), os.ModePerm)) 826 | testutil.Ok(t, os.WriteFile(filepath.Join(tmpDir, "obsctl", "test", "config.json"), []byte(""), os.ModePerm)) 827 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(tmpDir, "obsctl", "test", "config.json"))) 828 | 829 | tlogger := level.NewFilter(log.NewJSONLogger(log.NewSyncWriter(os.Stderr)), level.AllowDebug()) 830 | 831 | t.Run("empty config", func(t *testing.T) { 832 | cfg := Config{ 833 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 834 | } 835 | 836 | err := cfg.SetCurrentContext(tlogger, "stage", "first") 837 | testutil.NotOk(t, err) 838 | testutil.Equals(t, fmt.Errorf("api with name stage doesn't exist"), err) 839 | }) 840 | 841 | t.Run("config with one API no tenant", func(t *testing.T) { 842 | cfg := Config{ 843 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 844 | APIs: map[string]APIConfig{ 845 | "stage": {URL: "https://stage.api:9090", Contexts: nil}, 846 | }, 847 | } 848 | 849 | err := cfg.SetCurrentContext(tlogger, "stage", "first") 850 | 851 | testutil.NotOk(t, err) 852 | testutil.Equals(t, fmt.Errorf("tenant with name first doesn't exist in api stage"), err) 853 | }) 854 | 855 | t.Run("config with multiple API and no current", func(t *testing.T) { 856 | cfg := Config{ 857 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 858 | APIs: map[string]APIConfig{ 859 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 860 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 861 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 862 | }}, 863 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 864 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 865 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 866 | }}, 867 | }, 868 | } 869 | 870 | testutil.Ok(t, cfg.SetCurrentContext(tlogger, "prod", "first")) 871 | 872 | testutil.Equals(t, cfg.Current, struct { 873 | API string `json:"api"` 874 | Tenant string `json:"tenant"` 875 | }{ 876 | API: "prod", 877 | Tenant: "first", 878 | }) 879 | }) 880 | 881 | t.Run("config with multiple API and current", func(t *testing.T) { 882 | cfg := Config{ 883 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 884 | APIs: map[string]APIConfig{ 885 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 886 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 887 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 888 | }}, 889 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 890 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 891 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 892 | }}, 893 | }, 894 | Current: struct { 895 | API string `json:"api"` 896 | Tenant string `json:"tenant"` 897 | }{ 898 | API: "stage", 899 | Tenant: "second", 900 | }, 901 | } 902 | 903 | testutil.Ok(t, cfg.SetCurrentContext(tlogger, "prod", "first")) 904 | 905 | testutil.Equals(t, cfg.Current, struct { 906 | API string `json:"api"` 907 | Tenant string `json:"tenant"` 908 | }{ 909 | API: "prod", 910 | Tenant: "first", 911 | }) 912 | }) 913 | } 914 | 915 | func TestRemoveContext(t *testing.T) { 916 | tmpDir := t.TempDir() 917 | t.Cleanup(func() { testutil.Ok(t, os.RemoveAll(tmpDir)) }) 918 | testutil.Ok(t, os.MkdirAll(filepath.Join(tmpDir, "obsctl", "test"), os.ModePerm)) 919 | testutil.Ok(t, os.WriteFile(filepath.Join(tmpDir, "obsctl", "test", "config.json"), []byte(""), os.ModePerm)) 920 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(tmpDir, "obsctl", "test", "config.json"))) 921 | 922 | tlogger := level.NewFilter(log.NewJSONLogger(log.NewSyncWriter(os.Stderr)), level.AllowDebug()) 923 | 924 | t.Run("empty config", func(t *testing.T) { 925 | cfg := Config{ 926 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 927 | } 928 | 929 | err := cfg.RemoveContext(tlogger, "stage", "first") 930 | testutil.NotOk(t, err) 931 | testutil.Equals(t, fmt.Errorf("api with name stage doesn't exist"), err) 932 | }) 933 | 934 | t.Run("config with one API no tenant", func(t *testing.T) { 935 | cfg := Config{ 936 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 937 | APIs: map[string]APIConfig{ 938 | "stage": {URL: "https://stage.api:9090", Contexts: nil}, 939 | }, 940 | } 941 | 942 | err := cfg.RemoveContext(tlogger, "stage", "first") 943 | 944 | testutil.NotOk(t, err) 945 | testutil.Equals(t, fmt.Errorf("tenant with name first doesn't exist in api stage"), err) 946 | }) 947 | 948 | t.Run("config with one API and one tenant", func(t *testing.T) { 949 | cfg := Config{ 950 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 951 | APIs: map[string]APIConfig{ 952 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 953 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 954 | }}, 955 | }, 956 | } 957 | 958 | testutil.Ok(t, cfg.RemoveContext(tlogger, "stage", "first")) 959 | 960 | testutil.Equals(t, cfg.APIs, map[string]APIConfig{}) 961 | }) 962 | 963 | t.Run("config with multiple APIs and tenants", func(t *testing.T) { 964 | cfg := Config{ 965 | pathOverride: filepath.Join(tmpDir, "obsctl", "test", "config.json"), 966 | APIs: map[string]APIConfig{ 967 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 968 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 969 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 970 | }}, 971 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 972 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 973 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 974 | }}, 975 | }, 976 | } 977 | 978 | testutil.Ok(t, cfg.RemoveContext(tlogger, "stage", "second")) 979 | testutil.Ok(t, cfg.RemoveContext(tlogger, "prod", "first")) 980 | 981 | exp := map[string]APIConfig{ 982 | "stage": {URL: "https://stage.api:9090", Contexts: map[string]TenantConfig{ 983 | "first": {Tenant: "first", OIDC: &OIDCConfig{Audience: "obs", ClientID: "first", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 984 | }}, 985 | "prod": {URL: "https://prod.api:9090", Contexts: map[string]TenantConfig{ 986 | "second": {Tenant: "second", OIDC: &OIDCConfig{Audience: "obs", ClientID: "second", ClientSecret: "secret", IssuerURL: "sso.obs.com"}}, 987 | }}, 988 | } 989 | 990 | testutil.Equals(t, cfg.APIs, exp) 991 | }) 992 | } 993 | -------------------------------------------------------------------------------- /pkg/fetcher/request.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/go-kit/log" 9 | "github.com/go-kit/log/level" 10 | "github.com/observatorium/api/client" 11 | "github.com/observatorium/api/client/parameters" 12 | "github.com/observatorium/obsctl/pkg/config" 13 | ) 14 | 15 | // NewCustomFetcher returns a ClientWithResponses which is configured to use oauth HTTP Client. 16 | func NewCustomFetcher(ctx context.Context, logger log.Logger) (*client.ClientWithResponses, parameters.Tenant, error) { 17 | cfg, err := config.Read(logger) 18 | if err != nil { 19 | return nil, "", fmt.Errorf("getting reading config: %w", err) 20 | } 21 | 22 | c, err := cfg.Client(ctx, logger) 23 | if err != nil { 24 | return nil, "", fmt.Errorf("getting current client: %w", err) 25 | } 26 | 27 | fc, err := client.NewClientWithResponses(cfg.APIs[cfg.Current.API].URL, func(f *client.Client) error { 28 | f.Client = c 29 | return nil 30 | }, client.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error { 31 | level.Debug(logger).Log( 32 | "method", req.Method, 33 | "URL", req.URL, 34 | ) 35 | return nil 36 | })) 37 | if err != nil { 38 | return nil, "", fmt.Errorf("getting fetcher client: %w", err) 39 | } 40 | 41 | return fc, parameters.Tenant(cfg.Current.Tenant), nil 42 | } 43 | -------------------------------------------------------------------------------- /pkg/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httputil" 8 | "net/url" 9 | "path" 10 | "strings" 11 | 12 | "github.com/go-kit/log" 13 | "github.com/observatorium/obsctl/pkg/config" 14 | ) 15 | 16 | const prefixHeader = "X-Forwarded-Prefix" 17 | 18 | // NewProxyServer returns an HTTP reverse proxy server, based on current tenant and API context. 19 | // It also adds a /api//v1// path prefix to every request sent to it. 20 | // For example http://localhost:8080/api/v1/stores becomes https://myobsapi.com/api/metrics/v1/example-tenant/api/v1/stores. 21 | // This makes UIs like Thanos Querier fully functional. 22 | func NewProxyServer(ctx context.Context, logger log.Logger, resource, listenAddr string) (*http.Server, error) { 23 | cfg, err := config.Read(logger) 24 | if err != nil { 25 | return nil, fmt.Errorf("getting reading config: %w", err) 26 | } 27 | 28 | t, err := cfg.Transport(ctx, logger) 29 | if err != nil { 30 | return nil, fmt.Errorf("getting current transport: %w", err) 31 | } 32 | 33 | apiURL, err := url.Parse(cfg.APIs[cfg.Current.API].URL) 34 | if err != nil { 35 | return nil, fmt.Errorf("%s is not a valid URL", cfg.APIs[cfg.Current.API].URL) 36 | } 37 | 38 | // url.Parse might pass a URL with only path, so need to check here for scheme and host. 39 | // As per docs: https://pkg.go.dev/net/url#Parse. 40 | if apiURL.Host == "" || apiURL.Scheme == "" { 41 | return nil, fmt.Errorf("%s is not a valid URL (scheme: %s,host: %s)", apiURL, apiURL.Scheme, apiURL.Host) 42 | } 43 | 44 | p := httputil.ReverseProxy{ 45 | Director: func(request *http.Request) { 46 | request.URL.Scheme = apiURL.Scheme 47 | // Set the Host at both request and request.URL objects. 48 | request.Host = apiURL.Host 49 | request.URL.Host = apiURL.Host 50 | // Derive path from the paths of configured URL and request URL. 51 | request.URL.Path, request.URL.RawPath = joinURLPath(apiURL, request.URL, resource, cfg.APIs[cfg.Current.API].Contexts[cfg.Current.Tenant].Tenant) 52 | request.Header.Add(prefixHeader, "/") 53 | }, 54 | Transport: t, 55 | } 56 | 57 | return &http.Server{ 58 | Addr: listenAddr, 59 | Handler: &p, 60 | }, nil 61 | } 62 | 63 | func singleJoiningSlash(a, b string) string { 64 | bslash := strings.HasPrefix(b, "/") 65 | 66 | if bslash { 67 | return a + b 68 | } else { 69 | return a + "/" + b 70 | } 71 | } 72 | 73 | // Modification of 74 | // https://go.dev/src/net/http/httputil/reverseproxy.go#L116 75 | func joinURLPath(a, b *url.URL, resource, tenant string) (string, string) { 76 | if a.RawPath == "" && b.RawPath == "" { 77 | return singleJoiningSlash(path.Join(a.Path, "api/"+resource+"/v1/"+tenant), b.Path), "" 78 | } 79 | apath := a.EscapedPath() 80 | bpath := b.EscapedPath() 81 | 82 | apath = path.Join(apath, "api/"+resource+"/v1/"+tenant) 83 | a.Path = path.Join(a.Path, "api/"+resource+"/v1/"+tenant) 84 | 85 | bslash := strings.HasPrefix(bpath, "/") 86 | if bslash { 87 | return a.Path + b.Path, apath + bpath 88 | } else { 89 | return a.Path + "/" + b.Path, apath + "/" + bpath 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Version returns 'obsctl' version. 4 | const Version = "v0.1.0-dev" 5 | -------------------------------------------------------------------------------- /scripts/build-check-comments.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | # Reliable cross-platform comment checker for go files. 6 | # Uses docker and stable linux distro for reliable grep regex and 7 | # to make sure we work on Linux and OS X and oh even Windows! :) 8 | # Main issue is minor incompatibies between grep on various platforms. 9 | # 10 | # Linux: set USE_DOCKER=yes or USE_DOCKER=false to force run in docker 11 | # All other platforms: uses docker by default. Set USE_DOCKER=no or USE_DOCKER=false to override. 12 | # 13 | # Checks Go code comments if they have trailing period (excludes protobuffers and vendor files). 14 | # Comments with more than 3 spaces at beginning are omitted from the check, example: '// - foo'. 15 | # This does not include top-level commments for funcs and types. 16 | # 17 | # Example: 18 | # func main() { 19 | # // comment without period, will trigger check 20 | # //comment without leading space, will trigger check. 21 | # // comment without trailing space, will trigger check. 22 | # // good comment, leading space, ends with period, no trailing space. 23 | # // - more than 3 leading spaces, will pass 24 | # app := kingpin.New(filepath.Base(os.Args[0]), "A block storage based long-term storage for Prometheus") 25 | # } 26 | 27 | 28 | # Abs path to project dir and this script, should work on all OS's 29 | declare ROOT_DIR="$(cd $(dirname "${BASH_SOURCE}")/.. && pwd)" 30 | declare THIS_SCRIPT="$(cd $(dirname "${BASH_SOURCE}") && pwd)/$(basename "${BASH_SOURCE}")" 31 | 32 | # Image to use if we do docker-based commands. NB: busybox is no good for this. 33 | declare IMAGE="debian:9-slim" 34 | 35 | # User can explicitly ask to run in docker 36 | declare USE_DOCKER=${USE_DOCKER:=""} 37 | 38 | # For OS X, always use Docker as we have nasty 39 | # compat GNU/BSG issues with grep. 40 | if test "Linux" != "$(uname || true)" 41 | then 42 | # Allow overriding for non-linux platforms 43 | if test "no" != "${USE_DOCKER}" && test "false" != "${USE_DOCKER}" 44 | then 45 | USE_DOCKER="yes" 46 | fi 47 | fi 48 | 49 | if test "yes" == "${USE_DOCKER}" || test "true" == "${USE_DOCKER}" 50 | then 51 | # Make sure we only attach TTY if we have it, CI builds won't have it. 52 | declare TTY_FLAG="" 53 | if [ -t 1 ] 54 | then 55 | TTY_FLAG="-t" 56 | fi 57 | 58 | # Annoying issue with ownership of files in mapped volumes. 59 | # Need to run with same UID and GID in container as we do 60 | # on the machine, otherwise all output will be owned by root. 61 | # Doesn't happen on OS X but does on Linux. So we will do 62 | # UID and GID for Linux only (this won't work on OS X anyway). 63 | declare USER_FLAG="" 64 | if test "Linux" == "$(uname || true)" 65 | then 66 | USER_FLAG="-u $(id -u):$(id -g)" 67 | fi 68 | 69 | printf "\n\n\n This will run in Docker. \n If you get an error, ensure Docker is installed. \n\n\n" 70 | ( 71 | set -x 72 | docker run \ 73 | -i \ 74 | ${TTY_FLAG} \ 75 | ${USER_FLAG} \ 76 | --rm \ 77 | -v "${ROOT_DIR}":"${ROOT_DIR}":cached \ 78 | -w "${ROOT_DIR}" \ 79 | "${IMAGE}" \ 80 | "${THIS_SCRIPT}" 81 | ) 82 | exit 0 83 | fi 84 | 85 | function check_comments { 86 | # no bombing out on errors with grep 87 | set +e 88 | 89 | # This is quite mad but don't fear the https://regex101.com/ helps a lot. 90 | grep -Przo --color --include \*.go --exclude \*.pb.go --exclude bindata.go --exclude-dir vendor \ 91 | '\n.*\s+//(\s{0,3}[^\s^+][^\n]+[^.?!:]{2}|[^\s].*)\n[ \t]*[^/\s].*\n' ./ 92 | res=$? 93 | set -e 94 | 95 | # man grep: Normally, the exit status is 0 if selected lines are found and 1 otherwise. 96 | # But the exit status is 2 if an error occurred, unless the -q or --quiet or --silent 97 | # option is used and a selected line is found. 98 | if test "0" == "${res}" # found something 99 | then 100 | printf "\n\n\n Error: Found comments without trailing period. Comments has to be full sentences.\n\n\n" 101 | exit 1 102 | elif test "1" == "${res}" # nothing found, all clear 103 | then 104 | printf "\n\n\n All comment formatting is good, Spartan.\n\n\n" 105 | exit 0 106 | else # grep error 107 | printf "\n\n\n Hmmm something didn't work, issues with grep?.\n\n\n" 108 | exit 2 109 | fi 110 | } 111 | 112 | check_comments -------------------------------------------------------------------------------- /test/config/loki.yml: -------------------------------------------------------------------------------- 1 | auth_enabled: true 2 | 3 | server: 4 | http_listen_port: 3100 5 | 6 | ingester: 7 | lifecycler: 8 | address: 0.0.0.0 9 | ring: 10 | kvstore: 11 | store: inmemory 12 | replication_factor: 1 13 | final_sleep: 0s 14 | chunk_idle_period: 5m 15 | chunk_retain_period: 30s 16 | 17 | querier: 18 | engine: 19 | max_look_back_period: 5m 20 | timeout: 3m 21 | 22 | schema_config: 23 | configs: 24 | - from: 2019-01-01 25 | store: boltdb 26 | object_store: filesystem 27 | schema: v11 28 | index: 29 | prefix: index_ 30 | period: 168h 31 | 32 | storage_config: 33 | boltdb: 34 | directory: /tmp/loki/index 35 | 36 | filesystem: 37 | directory: /tmp/loki/chunks 38 | 39 | limits_config: 40 | enforce_metric_name: false 41 | reject_old_samples: false 42 | -------------------------------------------------------------------------------- /test/e2e/configs.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/efficientgo/e2e" 12 | "github.com/efficientgo/tools/core/pkg/testutil" 13 | "github.com/google/uuid" 14 | "github.com/observatorium/obsctl/pkg/config" 15 | "golang.org/x/oauth2" 16 | ) 17 | 18 | const tenantsYAMLTmpl = ` 19 | - id: %[1]s 20 | name: %[2]s 21 | oidc: 22 | clientID: %[3]s 23 | issuerURL: %[4]s/` 24 | 25 | func createTenantsYAML( 26 | t *testing.T, 27 | e e2e.Environment, 28 | issuerURL string, 29 | noOfTenants int, 30 | ) { 31 | 32 | var yamlContent []byte 33 | yamlContent = append(yamlContent, []byte(`tenants:`)...) 34 | 35 | for i := 0; i < noOfTenants; i++ { 36 | id := uuid.New() 37 | yamlContent = append(yamlContent, []byte( 38 | fmt.Sprintf( 39 | tenantsYAMLTmpl, 40 | id.String(), 41 | "test-oidc-"+fmt.Sprint(i), 42 | "observatorium-"+fmt.Sprint(i), 43 | "http://"+issuerURL), 44 | )...) 45 | 46 | } 47 | 48 | err := os.WriteFile( 49 | filepath.Join(e.SharedDir(), "config", "tenants.yaml"), 50 | yamlContent, 51 | os.FileMode(0755), 52 | ) 53 | 54 | testutil.Ok(t, err) 55 | } 56 | 57 | const rbacRoleBindingsYAML = `roleBindings: 58 | - name: test 59 | roles: 60 | - read-write 61 | subjects:` 62 | 63 | const rbacRoleBindingsYAMLTmpl = ` 64 | - kind: user 65 | name: %[1]s` 66 | 67 | const rbacRoleYAML = ` 68 | roles: 69 | - name: read-write 70 | permissions: 71 | - read 72 | - write 73 | resources: 74 | - metrics 75 | - logs 76 | tenants:` 77 | 78 | const rbacRoleYAMLTmpl = ` 79 | - %[1]s` 80 | 81 | func createRBACYAML( 82 | t *testing.T, 83 | e e2e.Environment, 84 | noOfTenants int, 85 | ) { 86 | 87 | var yamlContent []byte 88 | yamlContent = append(yamlContent, []byte(rbacRoleBindingsYAML)...) 89 | 90 | for i := 0; i < noOfTenants; i++ { 91 | yamlContent = append(yamlContent, []byte( 92 | fmt.Sprintf( 93 | rbacRoleBindingsYAMLTmpl, 94 | "user-"+fmt.Sprint(i)), 95 | )...) 96 | } 97 | 98 | yamlContent = append(yamlContent, []byte(rbacRoleYAML)...) 99 | 100 | for i := 0; i < noOfTenants; i++ { 101 | yamlContent = append(yamlContent, []byte( 102 | fmt.Sprintf( 103 | rbacRoleYAMLTmpl, 104 | "test-oidc-"+fmt.Sprint(i)), 105 | )...) 106 | } 107 | 108 | err := os.WriteFile( 109 | filepath.Join(e.SharedDir(), "config", "rbac.yaml"), 110 | yamlContent, 111 | os.FileMode(0755), 112 | ) 113 | 114 | testutil.Ok(t, err) 115 | } 116 | 117 | const rulesObjstoreYAMLTpl = ` 118 | type: S3 119 | config: 120 | bucket: %s 121 | endpoint: %s 122 | access_key: %s 123 | insecure: true 124 | secret_key: %s 125 | ` 126 | 127 | func createRulesObjstoreYAML( 128 | t *testing.T, 129 | e e2e.Environment, 130 | bucket, endpoint, accessKey, secretKey string, 131 | ) { 132 | yamlContent := []byte(fmt.Sprintf( 133 | rulesObjstoreYAMLTpl, 134 | bucket, 135 | endpoint, 136 | accessKey, 137 | secretKey, 138 | )) 139 | 140 | err := os.WriteFile( 141 | filepath.Join(e.SharedDir(), "config", "rules-objstore.yaml"), 142 | yamlContent, 143 | os.FileMode(0755), 144 | ) 145 | 146 | testutil.Ok(t, err) 147 | } 148 | 149 | func createObsctlConfigJson( 150 | t *testing.T, 151 | e e2e.Environment, 152 | issuerURL string, 153 | apiURL string, 154 | noOfTenants int, 155 | current int, 156 | ) { 157 | ctx := make(map[string]config.TenantConfig) 158 | 159 | for i := 0; i < noOfTenants; i++ { 160 | layout := "2006-01-02T15:04:05.000Z" 161 | str := "2022-03-20T16:49:34.000Z" 162 | ti, err := time.Parse(layout, str) 163 | testutil.Ok(t, err) 164 | 165 | ctx["test-oidc-"+fmt.Sprint(i)] = config.TenantConfig{ 166 | Tenant: "test-oidc-" + fmt.Sprint(i), 167 | OIDC: &config.OIDCConfig{ 168 | Audience: "observatorium-" + fmt.Sprint(i), 169 | ClientID: "user-" + fmt.Sprint(i), 170 | ClientSecret: "secret", 171 | IssuerURL: "http://" + issuerURL + "/", 172 | Token: &oauth2.Token{ 173 | Expiry: ti, 174 | TokenType: "bearer", 175 | AccessToken: "xyz", 176 | }, 177 | }, 178 | } 179 | } 180 | 181 | cfg := config.Config{ 182 | APIs: map[string]config.APIConfig{ 183 | "test-api": { 184 | URL: apiURL, 185 | Contexts: ctx, 186 | }, 187 | }, 188 | Current: struct { 189 | API string `json:"api"` 190 | Tenant string `json:"tenant"` 191 | }{ 192 | API: "test-api", 193 | Tenant: "test-oidc-" + fmt.Sprint(current), 194 | }, 195 | } 196 | 197 | file, err := os.OpenFile(filepath.Join(e.SharedDir(), "obsctl", "config.json"), os.O_RDWR|os.O_TRUNC|os.O_CREATE, 0755) 198 | testutil.Ok(t, err) 199 | 200 | encoder := json.NewEncoder(file) 201 | encoder.SetIndent("", " ") 202 | testutil.Ok(t, encoder.Encode(cfg)) 203 | 204 | testutil.Ok(t, err) 205 | } 206 | 207 | const prometheusRuleYAML = `groups: 208 | - interval: 30s 209 | name: test-firing-alert 210 | rules: 211 | - alert: TestFiringAlert 212 | annotations: 213 | description: Test firing alert 214 | message: Message of firing alert here 215 | summary: Summary of firing alert here 216 | expr: vector(1) 217 | for: 1m 218 | labels: 219 | severity: page 220 | ` 221 | 222 | func createPrometheusRulesYAML( 223 | t *testing.T, 224 | e e2e.Environment, 225 | ) { 226 | yamlContent := []byte(fmt.Sprint( 227 | prometheusRuleYAML, 228 | )) 229 | 230 | err := os.WriteFile( 231 | filepath.Join(e.SharedDir(), "obsctl", "prometheus-rules.yaml"), 232 | yamlContent, 233 | os.FileMode(0755), 234 | ) 235 | 236 | testutil.Ok(t, err) 237 | } 238 | 239 | const lokiRuleYAML = ` 240 | interval: 30s 241 | name: test-firing-alert 242 | rules: 243 | - alert: TestFiringAlert 244 | annotations: 245 | description: Test firing alert 246 | expr: | 247 | 1 > 0 248 | for: 1s 249 | labels: 250 | severity: page 251 | ` 252 | 253 | func createLokiRulesYAML( 254 | t *testing.T, 255 | e e2e.Environment, 256 | ) { 257 | yamlContent := []byte(fmt.Sprint( 258 | lokiRuleYAML, 259 | )) 260 | 261 | err := os.WriteFile( 262 | filepath.Join(e.SharedDir(), "obsctl", "loki-rules.yaml"), 263 | yamlContent, 264 | os.FileMode(0755), 265 | ) 266 | 267 | testutil.Ok(t, err) 268 | } 269 | 270 | const lokiYAMLTpl = `auth_enabled: true 271 | 272 | server: 273 | http_listen_port: 3100 274 | 275 | common: 276 | storage: 277 | s3: 278 | s3forcepathstyle: true 279 | access_key_id: %[1]s 280 | secret_access_key: %[2]s 281 | endpoint: %[3]s 282 | bucketnames: %[4]s 283 | insecure: true 284 | 285 | compactor: 286 | working_directory: /tmp/loki/compactor 287 | shared_store: s3 288 | compaction_interval: 5m 289 | 290 | distributor: 291 | ring: 292 | kvstore: 293 | store: inmemory 294 | 295 | ingester: 296 | lifecycler: 297 | address: 0.0.0.0 298 | ring: 299 | kvstore: 300 | store: inmemory 301 | replication_factor: 1 302 | 303 | final_sleep: 0s 304 | chunk_idle_period: 5m 305 | chunk_retain_period: 30s 306 | wal: 307 | dir: /tmp/loki/ingester/wal 308 | enabled: false 309 | 310 | querier: 311 | engine: 312 | max_look_back_period: 5m 313 | timeout: 3m 314 | 315 | ruler: 316 | storage: 317 | type: s3 318 | wal: 319 | dir: /tmp/loki/ruler/wal 320 | rule_path: /tmp/loki/ 321 | 322 | schema_config: 323 | configs: 324 | - from: 2019-01-01 325 | store: boltdb-shipper 326 | object_store: s3 327 | schema: v12 328 | index: 329 | prefix: index_ 330 | period: 24h 331 | 332 | storage_config: 333 | boltdb_shipper: 334 | active_index_directory: /tmp/loki/index 335 | cache_location: /tmp/loki/index_cache 336 | shared_store: s3 337 | 338 | limits_config: 339 | enforce_metric_name: false 340 | reject_old_samples: false 341 | 342 | ` 343 | 344 | func createLokiYAML( 345 | t *testing.T, 346 | e e2e.Environment, 347 | accessId, accessKey, endpoint, bucket string, 348 | ) { 349 | yamlContent := []byte(fmt.Sprintf(lokiYAMLTpl, accessId, accessKey, endpoint, bucket)) 350 | 351 | err := os.WriteFile( 352 | filepath.Join(e.SharedDir(), "config", "loki.yml"), 353 | yamlContent, 354 | os.FileMode(0755), 355 | ) 356 | 357 | testutil.Ok(t, err) 358 | } 359 | -------------------------------------------------------------------------------- /test/e2e/helpers.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/coreos/go-oidc/v3/oidc" 13 | "github.com/efficientgo/tools/core/pkg/testutil" 14 | "golang.org/x/oauth2/clientcredentials" 15 | ) 16 | 17 | func registerHydraUsers(t *testing.T, noOfTenants int) { 18 | dataTmpl := `{"audience": ["observatorium-%[1]s"], "client_id": "user-%[1]s", "client_secret": "secret", "grant_types": ["client_credentials"], "token_endpoint_auth_method": "client_secret_basic"}` 19 | 20 | for i := 0; i < noOfTenants; i++ { 21 | d := fmt.Sprintf(dataTmpl, fmt.Sprint(i), fmt.Sprint(i)) 22 | b := bytes.NewBuffer([]byte(d)) 23 | resp, err := http.Post("http://127.0.0.1:4445/clients", "application/json", b) 24 | testutil.Ok(t, err) 25 | 26 | if resp.StatusCode/100 != 2 { 27 | t.Fatal(resp.Body) 28 | } 29 | } 30 | } 31 | 32 | func obtainToken(t *testing.T, issuerURL string, current int) string { 33 | provider, err := oidc.NewProvider(context.Background(), "http://"+issuerURL+"/") 34 | testutil.Ok(t, err) 35 | 36 | ccc := clientcredentials.Config{ 37 | ClientID: "user-" + fmt.Sprint(current), 38 | ClientSecret: "secret", 39 | TokenURL: provider.Endpoint().TokenURL, 40 | Scopes: []string{"openid", "offline_access"}, 41 | } 42 | 43 | ccc.EndpointParams = url.Values{ 44 | "audience": []string{"observatorium-" + fmt.Sprint(current)}, 45 | } 46 | 47 | ts := ccc.TokenSource(context.Background()) 48 | 49 | tkn, err := ts.Token() 50 | testutil.Ok(t, err) 51 | return tkn.AccessToken 52 | } 53 | 54 | func assertResponse(t *testing.T, response string, expected string) { 55 | testutil.Assert( 56 | t, 57 | strings.Contains(response, expected), 58 | fmt.Sprintf("failed to assert that the response '%s' contains '%s'", response, expected), 59 | ) 60 | } 61 | 62 | func notAssertResponse(t *testing.T, response string, expected string) { 63 | testutil.Assert( 64 | t, 65 | !strings.Contains(response, expected), 66 | fmt.Sprintf("failed to assert that the response '%s' contains '%s'", response, expected), 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /test/e2e/hydra-for-macOS.yaml: -------------------------------------------------------------------------------- 1 | strategies: 2 | access_token: jwt 3 | urls: 4 | self: 5 | issuer: http://docker.for.mac.localhost:4444/ -------------------------------------------------------------------------------- /test/e2e/hydra.yaml: -------------------------------------------------------------------------------- 1 | strategies: 2 | access_token: jwt 3 | urls: 4 | self: 5 | issuer: http://172.17.0.1:4444/ -------------------------------------------------------------------------------- /test/e2e/kill_hydra.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PID=$(pgrep hydra) 4 | echo $PID 5 | kill -9 $PID 6 | 7 | exit 0 -------------------------------------------------------------------------------- /test/e2e/obsctl_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "runtime" 12 | "testing" 13 | "time" 14 | 15 | "github.com/efficientgo/e2e" 16 | "github.com/efficientgo/tools/core/pkg/testutil" 17 | "github.com/observatorium/obsctl/pkg/cmd" 18 | ) 19 | 20 | const ( 21 | envName = "obsctl-test" 22 | noOfTenants = 2 // Configure number of tenants. 23 | defaultTenant = 1 // Set default tenant to use. 24 | ) 25 | 26 | // preTest spins up all services required for metrics: 27 | // - Receive 28 | // - Query 29 | // - Rule 30 | // - Minio, Rules Objstore, Rule Syncer 31 | // - Up 32 | // - loki 33 | // Hydra is spun up externally via start_hydra.sh, as accessing it via docker network is difficult for obsctl. 34 | // Follows similar pattern as https://observatorium.io/docs/usage/getting-started.md/. 35 | // Also registers tenants in hydra. 36 | 37 | func preTest(t *testing.T) *e2e.DockerEnvironment { 38 | 39 | dir, err := os.Getwd() 40 | testutil.Ok(t, err) 41 | 42 | cmd := exec.Command("/bin/bash", dir+"/start_hydra.sh") 43 | cmd.Stdout = os.Stdout 44 | cmd.Stderr = os.Stderr 45 | 46 | testutil.Ok(t, cmd.Run()) 47 | 48 | e, err := e2e.NewDockerEnvironment(envName) 49 | testutil.Ok(t, err) 50 | t.Cleanup(e.Close) 51 | 52 | err = os.MkdirAll(filepath.Join(e.SharedDir(), "config"), 0750) 53 | testutil.Ok(t, err) 54 | 55 | hydraURL := "172.17.0.1:4444" 56 | switch runtime.GOOS { 57 | case "darwin": 58 | hydraURL = "docker.for.mac.localhost:4444" 59 | } 60 | 61 | registerHydraUsers(t, noOfTenants) // Only need to register this once. 62 | 63 | createTenantsYAML(t, e, hydraURL, noOfTenants) 64 | createRBACYAML(t, e, noOfTenants) 65 | 66 | bucket, endpoint, accessId, accessKey := startObjectStorageService(t, e) 67 | createLokiYAML(t, e, accessId, accessKey, endpoint, bucket) 68 | createRulesObjstoreYAML(t, e, bucket, endpoint, accessId, accessKey) 69 | 70 | read, write, rule := startServicesForMetrics(t, e, envName) 71 | logsEndpoint := startServicesForLogs(t, e) 72 | 73 | api, err := newObservatoriumAPIService(e, withMetricsEndpoints(read, write), withRulesEndpoint(rule), withLogsEndpoints(logsEndpoint)) 74 | testutil.Ok(t, err) 75 | testutil.Ok(t, e2e.StartAndWaitReady(api)) 76 | testutil.Ok(t, os.MkdirAll(filepath.Join(e.SharedDir(), "obsctl"), 0750)) // Create config file beforehand. 77 | 78 | createObsctlConfigJson(t, e, hydraURL, "http://"+api.Endpoint("http")+"/", noOfTenants, defaultTenant) 79 | 80 | token := obtainToken(t, hydraURL, defaultTenant) 81 | 82 | up, err := newUpRun( 83 | e, "up-metrics-read-write", "metrics", 84 | "http://"+api.InternalEndpoint("http")+"/api/metrics/v1/test-oidc-"+fmt.Sprint(defaultTenant)+"/api/v1/query", 85 | "http://"+api.InternalEndpoint("http")+"/api/metrics/v1/test-oidc-"+fmt.Sprint(defaultTenant)+"/api/v1/receive", 86 | withToken(token), 87 | withRunParameters(&runParams{period: "500ms", threshold: "1", latency: "10s", duration: "0"}), 88 | ) 89 | 90 | createPrometheusRulesYAML(t, e) 91 | createLokiRulesYAML(t, e) 92 | 93 | testutil.Ok(t, e2e.StartAndWaitReady(up)) 94 | testutil.Ok(t, err) 95 | 96 | up, err = newUpRun( 97 | e, "up-logs-read-write", "logs", 98 | "http://"+api.InternalEndpoint("http")+"/api/logs/v1/test-oidc-"+fmt.Sprint(defaultTenant)+"/loki/api/v1/query", 99 | "http://"+api.InternalEndpoint("http")+"/api/logs/v1/test-oidc-"+fmt.Sprint(defaultTenant)+"/loki/api/v1/push", 100 | withToken(token), 101 | withRunParameters(&runParams{period: "500ms", threshold: "1", latency: "10s", duration: "0"}), 102 | ) 103 | 104 | testutil.Ok(t, err) 105 | testutil.Ok(t, e2e.StartAndWaitReady(up)) 106 | 107 | time.Sleep(30 * time.Second) // Wait a bit for up to get some metrics in. 108 | 109 | return e 110 | 111 | } 112 | 113 | func TestObsctlMetricsCommands(t *testing.T) { 114 | 115 | e := preTest(t) 116 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(e.SharedDir(), "obsctl", "config.json"))) 117 | 118 | t.Run("get labels for a tenant", func(t *testing.T) { 119 | b := bytes.NewBufferString("") 120 | 121 | contextCmd := cmd.NewObsctlCmd(context.Background()) 122 | 123 | contextCmd.SetOut(b) 124 | contextCmd.SetArgs([]string{"metrics", "get", "labels"}) 125 | testutil.Ok(t, contextCmd.Execute()) 126 | 127 | got, err := io.ReadAll(b) 128 | testutil.Ok(t, err) 129 | 130 | exp := `{ 131 | "status": "success", 132 | "data": [ 133 | "__name__", 134 | "receive_replica", 135 | "tenant_id", 136 | "test" 137 | ] 138 | } 139 | 140 | ` 141 | testutil.Equals(t, exp, string(got)) 142 | }) 143 | 144 | t.Run("get labels for a tenant with match flag", func(t *testing.T) { 145 | b := bytes.NewBufferString("") 146 | 147 | contextCmd := cmd.NewObsctlCmd(context.Background()) 148 | 149 | contextCmd.SetOut(b) 150 | contextCmd.SetArgs([]string{"metrics", "get", "labels", "--match=observatorium_write"}) 151 | testutil.Ok(t, contextCmd.Execute()) 152 | 153 | got, err := io.ReadAll(b) 154 | testutil.Ok(t, err) 155 | 156 | // The response is the same with matcher too, as we have only one series with these exact labes 157 | exp := `{ 158 | "status": "success", 159 | "data": [ 160 | "__name__", 161 | "receive_replica", 162 | "tenant_id", 163 | "test" 164 | ] 165 | } 166 | 167 | ` 168 | 169 | testutil.Equals(t, exp, string(got)) 170 | }) 171 | 172 | t.Run("get labelvalues for a tenant", func(t *testing.T) { 173 | b := bytes.NewBufferString("") 174 | 175 | contextCmd := cmd.NewObsctlCmd(context.Background()) 176 | 177 | contextCmd.SetOut(b) 178 | contextCmd.SetArgs([]string{"metrics", "get", "labelvalues", "--name=test"}) 179 | testutil.Ok(t, contextCmd.Execute()) 180 | 181 | got, err := io.ReadAll(b) 182 | testutil.Ok(t, err) 183 | 184 | exp := `{ 185 | "status": "success", 186 | "data": [ 187 | "obsctl" 188 | ] 189 | } 190 | 191 | ` 192 | 193 | testutil.Equals(t, exp, string(got)) 194 | }) 195 | 196 | t.Run("get rules for a tenant (none configured)", func(t *testing.T) { 197 | b := bytes.NewBufferString("") 198 | 199 | contextCmd := cmd.NewObsctlCmd(context.Background()) 200 | 201 | contextCmd.SetOut(b) 202 | contextCmd.SetArgs([]string{"metrics", "get", "rules"}) 203 | testutil.Ok(t, contextCmd.Execute()) 204 | 205 | got, err := io.ReadAll(b) 206 | testutil.Ok(t, err) 207 | 208 | exp := `{ 209 | "status": "success", 210 | "data": { 211 | "groups": [] 212 | } 213 | } 214 | 215 | ` 216 | 217 | testutil.Equals(t, exp, string(got)) 218 | }) 219 | 220 | t.Run("get raw rules for a tenant (none configured)", func(t *testing.T) { 221 | b := bytes.NewBufferString("") 222 | 223 | contextCmd := cmd.NewObsctlCmd(context.Background()) 224 | 225 | contextCmd.SetOut(b) 226 | contextCmd.SetArgs([]string{"metrics", "get", "rules.raw"}) 227 | testutil.NotOk(t, contextCmd.Execute()) 228 | 229 | got, err := io.ReadAll(b) 230 | testutil.Ok(t, err) 231 | 232 | assertResponse(t, string(got), "no rules found") 233 | }) 234 | 235 | t.Run("set rules for a tenant", func(t *testing.T) { 236 | b := bytes.NewBufferString("") 237 | 238 | contextCmd := cmd.NewObsctlCmd(context.Background()) 239 | 240 | contextCmd.SetOut(b) 241 | contextCmd.SetArgs([]string{"metrics", "set", "--rule.file=" + filepath.Join(e.SharedDir(), "obsctl", "prometheus-rules.yaml")}) 242 | testutil.Ok(t, contextCmd.Execute()) 243 | 244 | got, err := io.ReadAll(b) 245 | testutil.Ok(t, err) 246 | 247 | exp := "successfully updated rules file\n" 248 | 249 | testutil.Equals(t, exp, string(got)) 250 | }) 251 | 252 | t.Run("get rules.raw for a tenant", func(t *testing.T) { 253 | b := bytes.NewBufferString("") 254 | 255 | contextCmd := cmd.NewObsctlCmd(context.Background()) 256 | 257 | contextCmd.SetOut(b) 258 | contextCmd.SetArgs([]string{"metrics", "get", "rules.raw"}) 259 | testutil.Ok(t, contextCmd.Execute()) 260 | 261 | got, err := io.ReadAll(b) 262 | testutil.Ok(t, err) 263 | 264 | // Using assertResponse here as we cannot know exact tenant_id. 265 | assertResponse(t, string(got), "TestFiringAlert") 266 | assertResponse(t, string(got), "tenant_id") 267 | }) 268 | 269 | t.Run("get rules for a tenant", func(t *testing.T) { 270 | b := bytes.NewBufferString("") 271 | 272 | contextCmd := cmd.NewObsctlCmd(context.Background()) 273 | 274 | contextCmd.SetOut(b) 275 | contextCmd.SetArgs([]string{"metrics", "get", "rules"}) 276 | 277 | time.Sleep(30 * time.Second) // Wait a bit for rules to get synced. 278 | 279 | testutil.Ok(t, contextCmd.Execute()) 280 | 281 | got, err := io.ReadAll(b) 282 | testutil.Ok(t, err) 283 | 284 | // Using assertResponse here as we cannot know exact tenant_id. 285 | // As this is response from Query /api/v1/rules, should contain health data. 286 | assertResponse(t, string(got), "TestFiringAlert") 287 | assertResponse(t, string(got), "tenant_id") 288 | assertResponse(t, string(got), "health") 289 | }) 290 | 291 | t.Run("get rules for a tenant with type flag", func(t *testing.T) { 292 | b := bytes.NewBufferString("") 293 | 294 | contextCmd := cmd.NewObsctlCmd(context.Background()) 295 | 296 | contextCmd.SetOut(b) 297 | contextCmd.SetArgs([]string{"metrics", "get", "rules", "--type=record"}) 298 | 299 | testutil.Ok(t, contextCmd.Execute()) 300 | 301 | got, err := io.ReadAll(b) 302 | testutil.Ok(t, err) 303 | 304 | notAssertResponse(t, string(got), "TestFiringAlert") 305 | notAssertResponse(t, string(got), "tenant_id") 306 | notAssertResponse(t, string(got), "health") 307 | }) 308 | 309 | t.Run("get series for a tenant", func(t *testing.T) { 310 | b := bytes.NewBufferString("") 311 | 312 | contextCmd := cmd.NewObsctlCmd(context.Background()) 313 | 314 | contextCmd.SetOut(b) 315 | contextCmd.SetArgs([]string{"metrics", "get", "series", "--match", "observatorium_write"}) 316 | testutil.Ok(t, contextCmd.Execute()) 317 | 318 | got, err := io.ReadAll(b) 319 | testutil.Ok(t, err) 320 | 321 | // Using assertResponse here as we cannot know exact tenant_id. 322 | // As this is response from Query /api/v1/series, it should contain label of series written by up. 323 | assertResponse(t, string(got), "observatorium_write") 324 | assertResponse(t, string(got), "tenant_id") 325 | assertResponse(t, string(got), "test") 326 | assertResponse(t, string(got), "obsctl") 327 | }) 328 | 329 | t.Run("query metrics for a tenant", func(t *testing.T) { 330 | b := bytes.NewBufferString("") 331 | 332 | contextCmd := cmd.NewObsctlCmd(context.Background()) 333 | 334 | contextCmd.SetOut(b) 335 | contextCmd.SetArgs([]string{"metrics", "query", "observatorium_write{test=\"obsctl\"}"}) 336 | testutil.Ok(t, contextCmd.Execute()) 337 | 338 | got, err := io.ReadAll(b) 339 | testutil.Ok(t, err) 340 | 341 | assertResponse(t, string(got), "observatorium_write") 342 | assertResponse(t, string(got), "tenant_id") 343 | assertResponse(t, string(got), "test") 344 | assertResponse(t, string(got), "obsctl") 345 | assertResponse(t, string(got), "metric") 346 | assertResponse(t, string(got), "resultType") 347 | assertResponse(t, string(got), "vector") 348 | }) 349 | 350 | t.Cleanup(func() { 351 | 352 | dir, err := os.Getwd() 353 | testutil.Ok(t, err) 354 | 355 | cmd := exec.Command("/bin/bash", dir+"/kill_hydra.sh") 356 | cmd.Stdout = os.Stdout 357 | cmd.Stderr = os.Stderr 358 | 359 | testutil.Ok(t, cmd.Run()) 360 | 361 | }) 362 | 363 | } 364 | 365 | func TestObsctlLogsCommands(t *testing.T) { 366 | 367 | e := preTest(t) 368 | testutil.Ok(t, os.Setenv("OBSCTL_CONFIG_PATH", filepath.Join(e.SharedDir(), "obsctl", "config.json"))) 369 | 370 | t.Run("get labels for a tenant", func(t *testing.T) { 371 | b := bytes.NewBufferString("") 372 | 373 | contextCmd := cmd.NewObsctlCmd(context.Background()) 374 | 375 | contextCmd.SetOut(b) 376 | contextCmd.SetArgs([]string{"logs", "get", "labels"}) 377 | testutil.Ok(t, contextCmd.Execute()) 378 | 379 | got, err := io.ReadAll(b) 380 | testutil.Ok(t, err) 381 | 382 | exp := `{ 383 | "status": "success", 384 | "data": [ 385 | "__name__", 386 | "test" 387 | ] 388 | } 389 | 390 | ` 391 | 392 | testutil.Equals(t, exp, string(got)) 393 | }) 394 | 395 | t.Run("get labelvalues for a tenant", func(t *testing.T) { 396 | b := bytes.NewBufferString("") 397 | 398 | contextCmd := cmd.NewObsctlCmd(context.Background()) 399 | 400 | contextCmd.SetOut(b) 401 | contextCmd.SetArgs([]string{"logs", "get", "labelvalues", "--name=test"}) 402 | testutil.Ok(t, contextCmd.Execute()) 403 | 404 | got, err := io.ReadAll(b) 405 | testutil.Ok(t, err) 406 | 407 | exp := `{ 408 | "status": "success", 409 | "data": [ 410 | "obsctl" 411 | ] 412 | } 413 | 414 | ` 415 | testutil.Equals(t, exp, string(got)) 416 | }) 417 | 418 | t.Run("get series for a tenant", func(t *testing.T) { 419 | b := bytes.NewBufferString("") 420 | 421 | contextCmd := cmd.NewObsctlCmd(context.Background()) 422 | 423 | contextCmd.SetOut(b) 424 | contextCmd.SetArgs([]string{"logs", "get", "series", "--match", "observatorium_write"}) 425 | testutil.Ok(t, contextCmd.Execute()) 426 | 427 | got, err := io.ReadAll(b) 428 | testutil.Ok(t, err) 429 | 430 | // Using assertResponse here as we cannot know exact tenant_id. 431 | // As this is response from Query /api/v1/series, it should contain label of series written by up. 432 | assertResponse(t, string(got), "observatorium_write") 433 | assertResponse(t, string(got), "tenant_id") 434 | assertResponse(t, string(got), "test") 435 | assertResponse(t, string(got), "obsctl") 436 | assertResponse(t, string(got), "receive_replica") 437 | 438 | }) 439 | 440 | t.Run("query logs for a tenant", func(t *testing.T) { 441 | b := bytes.NewBufferString("") 442 | 443 | contextCmd := cmd.NewObsctlCmd(context.Background()) 444 | 445 | contextCmd.SetOut(b) 446 | contextCmd.SetArgs([]string{"logs", "query", "{test=\"obsctl\"}"}) 447 | testutil.Ok(t, contextCmd.Execute()) 448 | 449 | got, err := io.ReadAll(b) 450 | testutil.Ok(t, err) 451 | 452 | assertResponse(t, string(got), "observatorium_write") 453 | assertResponse(t, string(got), "__name__") 454 | assertResponse(t, string(got), "log line 1") 455 | assertResponse(t, string(got), "test") 456 | assertResponse(t, string(got), "obsctl") 457 | assertResponse(t, string(got), "stream") 458 | assertResponse(t, string(got), "values") 459 | assertResponse(t, string(got), "resultType") 460 | assertResponse(t, string(got), "streams") 461 | }) 462 | 463 | t.Run("get rules for a tenant (none configured)", func(t *testing.T) { 464 | b := bytes.NewBufferString("") 465 | 466 | contextCmd := cmd.NewObsctlCmd(context.Background()) 467 | 468 | contextCmd.SetOut(b) 469 | contextCmd.SetArgs([]string{"logs", "get", "rules"}) 470 | testutil.Ok(t, contextCmd.Execute()) 471 | 472 | got, err := io.ReadAll(b) 473 | testutil.Ok(t, err) 474 | 475 | exp := `{ 476 | "status": "success", 477 | "data": { 478 | "groups": [] 479 | }, 480 | "errorType": "", 481 | "error": "" 482 | } 483 | ` 484 | 485 | testutil.Equals(t, exp, string(got)) 486 | }) 487 | 488 | t.Run("get raw rules for a tenant (none configured)", func(t *testing.T) { 489 | b := bytes.NewBufferString("") 490 | 491 | contextCmd := cmd.NewObsctlCmd(context.Background()) 492 | 493 | contextCmd.SetOut(b) 494 | contextCmd.SetArgs([]string{"logs", "get", "rules.raw"}) 495 | err := contextCmd.Execute() 496 | testutil.NotOk(t, err) 497 | 498 | assertResponse(t, err.Error(), "no rule groups found") 499 | }) 500 | 501 | t.Run("set rules for a tenant", func(t *testing.T) { 502 | b := bytes.NewBufferString("") 503 | 504 | contextCmd := cmd.NewObsctlCmd(context.Background()) 505 | 506 | contextCmd.SetOut(b) 507 | contextCmd.SetArgs([]string{"logs", "set", "--namespace", "logs", "--rule.file=" + filepath.Join(e.SharedDir(), "obsctl", "loki-rules.yaml")}) 508 | testutil.Ok(t, contextCmd.Execute()) 509 | 510 | got, err := io.ReadAll(b) 511 | testutil.Ok(t, err) 512 | 513 | exp := `{"status":"success","data":null,"errorType":"","error":""} 514 | ` 515 | 516 | testutil.Equals(t, exp, string(got)) 517 | }) 518 | 519 | t.Run("get all rules.raw namespaces for a tenant", func(t *testing.T) { 520 | b := bytes.NewBufferString("") 521 | 522 | contextCmd := cmd.NewObsctlCmd(context.Background()) 523 | 524 | contextCmd.SetOut(b) 525 | contextCmd.SetArgs([]string{"logs", "get", "rules.raw"}) 526 | testutil.Ok(t, contextCmd.Execute()) 527 | 528 | got, err := io.ReadAll(b) 529 | testutil.Ok(t, err) 530 | 531 | assertResponse(t, string(got), "TestFiringAlert") 532 | }) 533 | 534 | t.Run("get all rules.raw groups in namespace for a tenant", func(t *testing.T) { 535 | b := bytes.NewBufferString("") 536 | 537 | contextCmd := cmd.NewObsctlCmd(context.Background()) 538 | 539 | contextCmd.SetOut(b) 540 | contextCmd.SetArgs([]string{"logs", "get", "rules.raw", "--namespace", "logs"}) 541 | testutil.Ok(t, contextCmd.Execute()) 542 | 543 | got, err := io.ReadAll(b) 544 | testutil.Ok(t, err) 545 | 546 | assertResponse(t, string(got), "TestFiringAlert") 547 | }) 548 | 549 | t.Run("get a rules.raw group in namespace for a tenant", func(t *testing.T) { 550 | b := bytes.NewBufferString("") 551 | 552 | contextCmd := cmd.NewObsctlCmd(context.Background()) 553 | 554 | contextCmd.SetOut(b) 555 | contextCmd.SetArgs([]string{"logs", "get", "rules.raw", "--namespace", "logs", "--group", "test-firing-alert"}) 556 | testutil.Ok(t, contextCmd.Execute()) 557 | 558 | got, err := io.ReadAll(b) 559 | testutil.Ok(t, err) 560 | 561 | assertResponse(t, string(got), "TestFiringAlert") 562 | }) 563 | 564 | t.Run("get rules for a tenant", func(t *testing.T) { 565 | b := bytes.NewBufferString("") 566 | 567 | contextCmd := cmd.NewObsctlCmd(context.Background()) 568 | 569 | contextCmd.SetOut(b) 570 | contextCmd.SetArgs([]string{"logs", "get", "rules"}) 571 | 572 | time.Sleep(30 * time.Second) // Wait a bit for rules to get evaluated. 573 | 574 | testutil.Ok(t, contextCmd.Execute()) 575 | 576 | got, err := io.ReadAll(b) 577 | testutil.Ok(t, err) 578 | 579 | assertResponse(t, string(got), "TestFiringAlert") 580 | }) 581 | 582 | t.Run("get alerts for a tenant", func(t *testing.T) { 583 | b := bytes.NewBufferString("") 584 | 585 | contextCmd := cmd.NewObsctlCmd(context.Background()) 586 | 587 | contextCmd.SetOut(b) 588 | contextCmd.SetArgs([]string{"logs", "get", "alerts"}) 589 | 590 | time.Sleep(30 * time.Second) // Wait a bit for rules to get evaluated. 591 | 592 | testutil.Ok(t, contextCmd.Execute()) 593 | 594 | got, err := io.ReadAll(b) 595 | testutil.Ok(t, err) 596 | 597 | assertResponse(t, string(got), "TestFiringAlert") 598 | }) 599 | 600 | t.Cleanup(func() { 601 | dir, err := os.Getwd() 602 | testutil.Ok(t, err) 603 | 604 | cmd := exec.Command("/bin/bash", dir+"/kill_hydra.sh") 605 | cmd.Stdout = os.Stdout 606 | cmd.Stderr = os.Stderr 607 | 608 | testutil.Ok(t, cmd.Run()) 609 | }) 610 | } 611 | -------------------------------------------------------------------------------- /test/e2e/services.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strconv" 7 | "testing" 8 | "time" 9 | 10 | "github.com/efficientgo/e2e" 11 | e2edb "github.com/efficientgo/e2e/db" 12 | "github.com/efficientgo/tools/core/pkg/testutil" 13 | ) 14 | 15 | // Adapted from https://github.com/observatorium/api/blob/main/test/e2e/services.go. 16 | 17 | const ( 18 | apiImage = "quay.io/observatorium/api:latest" 19 | upImage = "quay.io/observatorium/up:master-2021-02-12-03ef2f2" 20 | thanosImage = "quay.io/thanos/thanos:v0.25.1" 21 | thanosRuleSyncerImage = "quay.io/observatorium/thanos-rule-syncer:main-2022-02-01-d4c24bc" 22 | rulesObjectStoreImage = "quay.io/observatorium/rules-objstore:main-2022-01-19-8650540" 23 | lokiImage = "grafana/loki:2.6.1" 24 | 25 | logLevelError = "error" 26 | ) 27 | 28 | type apiOptions struct { 29 | metricsReadEndpoint string 30 | metricsWriteEndpoint string 31 | metricsRulesEndpoint string 32 | logsEndpoint string 33 | } 34 | 35 | type apiOption func(*apiOptions) 36 | 37 | func withMetricsEndpoints(readEndpoint string, writeEndpoint string) apiOption { 38 | return func(o *apiOptions) { 39 | o.metricsReadEndpoint = readEndpoint 40 | o.metricsWriteEndpoint = writeEndpoint 41 | } 42 | } 43 | 44 | func withRulesEndpoint(rulesEndpoint string) apiOption { 45 | return func(o *apiOptions) { 46 | o.metricsRulesEndpoint = rulesEndpoint 47 | } 48 | } 49 | 50 | func withLogsEndpoints(endpoint string) apiOption { 51 | return func(o *apiOptions) { 52 | o.logsEndpoint = endpoint 53 | } 54 | } 55 | 56 | func newObservatoriumAPIService( 57 | e e2e.Environment, 58 | options ...apiOption, 59 | ) (e2e.InstrumentedRunnable, error) { 60 | opts := apiOptions{} 61 | for _, o := range options { 62 | o(&opts) 63 | } 64 | 65 | ports := map[string]int{ 66 | "http": 8443, 67 | "http-internal": 8448, 68 | } 69 | 70 | args := e2e.BuildArgs(map[string]string{ 71 | "--web.listen": ":" + strconv.Itoa(ports["http"]), 72 | "--web.internal.listen": ":" + strconv.Itoa(ports["http-internal"]), 73 | "--web.healthchecks.url": "http://127.0.0.1:8443", 74 | "--rbac.config": filepath.Join("/shared/config", "rbac.yaml"), 75 | "--tenants.config": filepath.Join("/shared/config", "tenants.yaml"), 76 | "--log.level": logLevelError, 77 | }) 78 | 79 | if opts.metricsReadEndpoint != "" && opts.metricsWriteEndpoint != "" { 80 | args = append(args, "--metrics.read.endpoint="+"http://"+opts.metricsReadEndpoint) 81 | args = append(args, "--metrics.write.endpoint="+"http://"+opts.metricsWriteEndpoint) 82 | } 83 | 84 | if opts.metricsRulesEndpoint != "" { 85 | args = append(args, "--metrics.rules.endpoint="+"http://"+opts.metricsRulesEndpoint) 86 | } 87 | 88 | if opts.logsEndpoint != "" { 89 | args = append(args, "--logs.read.endpoint="+"http://"+opts.logsEndpoint) 90 | args = append(args, "--logs.tail.endpoint="+"http://"+opts.logsEndpoint) 91 | args = append(args, "--logs.write.endpoint="+"http://"+opts.logsEndpoint) 92 | args = append(args, "--logs.rules.endpoint="+"http://"+opts.logsEndpoint) 93 | } 94 | 95 | return e2e.NewInstrumentedRunnable(e, "observatorium_api").WithPorts(ports, "http-internal").Init( 96 | e2e.StartOptions{ 97 | Image: apiImage, 98 | Command: e2e.NewCommandWithoutEntrypoint("observatorium-api", args...), 99 | Readiness: e2e.NewHTTPReadinessProbe("http-internal", "/ready", 200, 200), 100 | User: strconv.Itoa(os.Getuid()), 101 | }, 102 | ), nil 103 | } 104 | 105 | func newThanosReceiveService(e e2e.Environment) e2e.InstrumentedRunnable { 106 | ports := map[string]int{ 107 | "http": 10902, 108 | "grpc": 10901, 109 | "remote_write": 19291, 110 | } 111 | 112 | args := e2e.BuildArgs(map[string]string{ 113 | "--receive.local-endpoint": "0.0.0.0:" + strconv.Itoa(ports["grpc"]), 114 | "--label": "receive_replica=\"0\"", 115 | "--grpc-address": "0.0.0.0:" + strconv.Itoa(ports["grpc"]), 116 | "--http-address": "0.0.0.0:" + strconv.Itoa(ports["http"]), 117 | "--remote-write.address": "0.0.0.0:" + strconv.Itoa(ports["remote_write"]), 118 | "--log.level": logLevelError, 119 | "--tsdb.path": "/tmp", 120 | }) 121 | 122 | return e2e.NewInstrumentedRunnable(e, "thanos-receive").WithPorts(ports, "http").Init( 123 | e2e.StartOptions{ 124 | Image: thanosImage, 125 | Command: e2e.NewCommand("receive", args...), 126 | Readiness: e2e.NewHTTPReadinessProbe("http", "/-/ready", 200, 200), 127 | User: strconv.Itoa(os.Getuid()), 128 | }, 129 | ) 130 | } 131 | 132 | func newRulesObjstoreService(e e2e.Environment) e2e.InstrumentedRunnable { 133 | ports := map[string]int{"http": 8080, "internal": 8081} 134 | 135 | args := e2e.BuildArgs(map[string]string{ 136 | "--log.level": logLevelError, 137 | "--web.listen": ":" + strconv.Itoa(ports["http"]), 138 | "--web.internal.listen": ":" + strconv.Itoa(ports["internal"]), 139 | "--web.healthchecks.url": "http://127.0.0.1:" + strconv.Itoa(ports["http"]), 140 | "--objstore.config-file": filepath.Join("/shared/config", "rules-objstore.yaml"), 141 | }) 142 | 143 | return e2e.NewInstrumentedRunnable(e, "rules_objstore").WithPorts(ports, "internal").Init( 144 | e2e.StartOptions{ 145 | Image: rulesObjectStoreImage, 146 | Command: e2e.NewCommand("", args...), 147 | Readiness: e2e.NewHTTPReadinessProbe("internal", "/ready", 200, 200), 148 | User: strconv.Itoa(os.Getuid()), 149 | }, 150 | ) 151 | } 152 | 153 | func newRuleSyncerService(e e2e.Environment, ruler string, rulesObjstore string) e2e.InstrumentedRunnable { 154 | ports := map[string]int{"http": 10911} 155 | args := e2e.BuildArgs(map[string]string{ 156 | "--file": filepath.Join("/shared/config", "rules.yaml"), 157 | "--rules-backend-url": "http://" + rulesObjstore, 158 | "--thanos-rule-url": "http://" + ruler, 159 | }) 160 | 161 | return e2e.NewInstrumentedRunnable(e, "rule_syncer").WithPorts(ports, "http").Init( 162 | e2e.StartOptions{ 163 | Image: thanosRuleSyncerImage, 164 | Command: e2e.NewCommand("", args...), 165 | User: strconv.Itoa(os.Getuid()), 166 | }, 167 | ) 168 | } 169 | 170 | func newThanosRulerService(e e2e.Environment, query string) e2e.InstrumentedRunnable { 171 | ports := map[string]int{ 172 | "http": 10904, 173 | "grpc": 10903, 174 | } 175 | 176 | args := e2e.BuildArgs(map[string]string{ 177 | "--label": "rule_replica=\"0\"", 178 | "--grpc-address": "0.0.0.0:" + strconv.Itoa(ports["grpc"]), 179 | "--http-address": "0.0.0.0:" + strconv.Itoa(ports["http"]), 180 | "--rule-file": filepath.Join("/shared/config", "rules.yaml"), 181 | "--query": query, 182 | "--log.level": logLevelError, 183 | "--data-dir": "/tmp", 184 | }) 185 | 186 | return e2e.NewInstrumentedRunnable(e, "thanos-ruler").WithPorts(ports, "http").Init( 187 | e2e.StartOptions{ 188 | Image: thanosImage, 189 | Command: e2e.NewCommand("rule", args...), 190 | Readiness: e2e.NewHTTPReadinessProbe("http", "/-/ready", 200, 200), 191 | User: strconv.Itoa(os.Getuid()), 192 | }, 193 | ) 194 | } 195 | 196 | func startObjectStorageService(t *testing.T, e e2e.Environment) (string, string, string, string) { 197 | bucket := "rulesobjstore" 198 | 199 | minio := e2edb.NewMinio(e, "rules-minio", bucket) 200 | testutil.Ok(t, e2e.StartAndWaitReady(minio)) 201 | 202 | return bucket, minio.InternalEndpoint(e2edb.AccessPortName), e2edb.MinioAccessKey, e2edb.MinioSecretKey 203 | } 204 | 205 | func startServicesForMetrics(t *testing.T, e e2e.Environment, envName string) (string, string, string) { 206 | thanosReceive := newThanosReceiveService(e) 207 | thanosRule := newThanosRulerService(e, "http://"+envName+"-"+"thanos-query:"+"9090") 208 | thanosQuery := e2edb.NewThanosQuerier( 209 | e, 210 | "thanos-query", 211 | []string{thanosReceive.InternalEndpoint("grpc"), thanosRule.InternalEndpoint("grpc")}, 212 | e2edb.WithImage(thanosImage), 213 | ) 214 | 215 | testutil.Ok(t, e2e.StartAndWaitReady(thanosReceive, thanosQuery, thanosRule)) 216 | 217 | rulesObjstore := newRulesObjstoreService(e) 218 | 219 | rulesSyncer := newRuleSyncerService(e, thanosRule.InternalEndpoint("http"), rulesObjstore.InternalEndpoint("http")) 220 | 221 | testutil.Ok(t, e2e.StartAndWaitReady(rulesObjstore)) 222 | testutil.Ok(t, e2e.StartAndWaitReady(rulesSyncer)) 223 | 224 | return thanosQuery.InternalEndpoint("http"), 225 | thanosReceive.InternalEndpoint("remote_write"), 226 | rulesObjstore.InternalEndpoint("http") 227 | } 228 | 229 | func startServicesForLogs(t *testing.T, e e2e.Environment) ( 230 | logsEndpoint string, 231 | ) { 232 | 233 | loki := newLokiService(e) 234 | testutil.Ok(t, e2e.StartAndWaitReady(loki)) 235 | 236 | return loki.InternalEndpoint("http") 237 | } 238 | 239 | func newLokiService(e e2e.Environment) e2e.InstrumentedRunnable { 240 | ports := map[string]int{"http": 3100, "grpc": 9095} 241 | 242 | args := e2e.BuildArgs(map[string]string{ 243 | "-config.file": filepath.Join("/shared/config", "loki.yml"), 244 | "-server.grpc-listen-address": "0.0.0.0", 245 | "-server.grpc-listen-port": strconv.Itoa(ports["grpc"]), 246 | "-server.http-listen-address": "0.0.0.0", 247 | "-server.http-listen-port": strconv.Itoa(ports["http"]), 248 | "-target": "all", 249 | "-log.level": logLevelError, 250 | }) 251 | 252 | return e2e.NewInstrumentedRunnable(e, "loki").WithPorts(ports, "http").Init( 253 | e2e.StartOptions{ 254 | Image: lokiImage, 255 | Command: e2e.NewCommandWithoutEntrypoint("loki", args...), 256 | // It takes ~1m before Loki's ingester starts reporting 200, 257 | // but it does not seem to affect tests, therefore we accept 258 | // 503 here as well to save time. 259 | Readiness: e2e.NewHTTPReadinessProbe("http", "/ready", 200, 503), 260 | User: strconv.Itoa(os.Getuid()), 261 | }, 262 | ) 263 | } 264 | 265 | type runParams struct { 266 | initialDelay string 267 | period string 268 | latency string 269 | threshold string 270 | duration string 271 | } 272 | 273 | type upOptions struct { 274 | token string 275 | runParams *runParams 276 | } 277 | 278 | type upOption func(*upOptions) 279 | 280 | func withToken(token string) upOption { 281 | return func(o *upOptions) { 282 | o.token = token 283 | } 284 | } 285 | 286 | func withRunParameters(params *runParams) upOption { 287 | return func(o *upOptions) { 288 | o.runParams = params 289 | } 290 | } 291 | 292 | func newUpRun( 293 | env e2e.Environment, 294 | name string, 295 | tt string, 296 | readEndpoint, writeEndpoint string, 297 | options ...upOption, 298 | ) (e2e.InstrumentedRunnable, error) { 299 | opts := upOptions{} 300 | for _, o := range options { 301 | o(&opts) 302 | } 303 | 304 | ports := map[string]int{ 305 | "http": 8888, 306 | } 307 | 308 | timeFn := func() string { return strconv.FormatInt(time.Now().UnixNano(), 10) } 309 | 310 | args := e2e.BuildArgs(map[string]string{ 311 | "--listen": "0.0.0.0:" + strconv.Itoa(ports["http"]), 312 | "--endpoint-type": tt, 313 | "--endpoint-read": readEndpoint, 314 | "--endpoint-write": writeEndpoint, 315 | "--log.level": logLevelError, 316 | "--name": "observatorium_write", 317 | "--labels": "test=\"obsctl\"", 318 | }) 319 | 320 | if tt == "logs" { 321 | args = append(args, "--logs=[\""+timeFn()+"\",\"log line 1\"]") 322 | } 323 | 324 | if opts.token != "" { 325 | args = append(args, "--token="+opts.token) 326 | } 327 | 328 | if opts.runParams != nil { 329 | if opts.runParams.initialDelay != "" { 330 | args = append(args, "--initial-query-delay="+opts.runParams.initialDelay) 331 | } 332 | if opts.runParams.duration != "" { 333 | args = append(args, "--duration="+opts.runParams.duration) 334 | } 335 | if opts.runParams.latency != "" { 336 | args = append(args, "--latency="+opts.runParams.latency) 337 | } 338 | if opts.runParams.threshold != "" { 339 | args = append(args, "--threshold="+opts.runParams.threshold) 340 | } 341 | if opts.runParams.period != "" { 342 | args = append(args, "--period="+opts.runParams.period) 343 | } 344 | } 345 | 346 | return e2e.NewInstrumentedRunnable(env, name).WithPorts(ports, "http").Init( 347 | e2e.StartOptions{ 348 | Image: upImage, 349 | Command: e2e.NewCommandWithoutEntrypoint("up", args...), 350 | User: strconv.Itoa(os.Getuid()), 351 | }, 352 | ), nil 353 | } 354 | -------------------------------------------------------------------------------- /test/e2e/start_hydra.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -o pipefail 5 | 6 | trap 'kill 0' SIGTERM 7 | 8 | OS="$(uname)" 9 | case $OS in 10 | 'Linux') 11 | HYDRA_OS='linux' 12 | HYDRA_CONFIG='hydra.yaml' 13 | ;; 14 | 'Darwin') 15 | HYDRA_OS='macos' 16 | HYDRA_CONFIG='hydra-for-macOS.yaml' 17 | ;; 18 | *) 19 | echo "Unsupported OS for this script: $OS" 20 | exit 1 21 | ;; 22 | esac 23 | 24 | WD="$(pwd)" 25 | gethydra() { 26 | mkdir -p mkdir -p $WD/tmp/bin 27 | echo "-------------------------------------------" 28 | echo "- Downloading ORY Hydra... -" 29 | echo "-------------------------------------------" 30 | curl -L "https://github.com/ory/hydra/releases/download/v1.9.1/hydra_1.9.1-sqlite_${HYDRA_OS}_64bit.tar.gz" | tar -xzf - -C $WD/tmp/bin hydra 31 | } 32 | startHydra() { 33 | (DSN=memory $WD/tmp/bin/hydra serve all --dangerous-force-http --config $WD/$HYDRA_CONFIG &>/dev/null) & 34 | echo "-------------------------------------------" 35 | echo "- Waiting for Hydra to come up... -" 36 | echo "-------------------------------------------" 37 | until curl --output /dev/null --silent --fail --insecure http://127.0.0.1:4444/.well-known/openid-configuration; do 38 | printf '.' 39 | sleep 1 40 | done 41 | echo "" 42 | } 43 | 44 | gethydra 45 | startHydra 46 | exit 0 47 | --------------------------------------------------------------------------------