├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── Makefile ├── README.md ├── api ├── api.go ├── api_test.go ├── backup.go ├── cluster.go ├── cluster_test.go ├── deps.go ├── jwt.go ├── jwt_test.go ├── mock_cluster_service_test.go ├── streamlogs.go └── userauth.go ├── architecture.png ├── build └── build.go ├── config.example.yaml ├── config └── config.go ├── go.mod ├── go.sum ├── internal ├── cmd │ ├── root.go │ ├── start.go │ └── version.go ├── dockerservice │ ├── container.go │ ├── container_test.go │ ├── dockerservice.go │ └── dockerservice_test.go ├── metastore │ ├── metastore.go │ └── metastore_test.go ├── monitor │ ├── monitor.go │ ├── pg-exporter-dashboard.json │ └── runtime.go ├── postgres │ └── postgres.go └── service │ ├── backup_service.go │ ├── cluster_service.go │ ├── cluster_service_test.go │ └── modify-pghba.sh ├── main.go ├── metrics └── metrics.go ├── misc ├── misc.go └── misc_test.go ├── openapi ├── cluster.go ├── docs.go └── swagger.yaml ├── scripts ├── install-spinup-dev.sh └── install-spinup.sh ├── spinup-backend.service ├── tests └── dockertest.go ├── testutils └── testutil.go └── utils └── logger.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Run build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | go-version: [ '1.18', '1.19', '1.20' ] 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Setup Go ${{ matrix.go-version }} 16 | uses: actions/setup-go@v3 17 | with: 18 | go-version: ${{ matrix.go-version }} 19 | 20 | - name: Build Binary 21 | run: make build 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | name: "pre-release" 6 | jobs: 7 | releases-matrix: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | # build and publish in parallel: linux/386, linux/amd64, windows/386, windows/amd64, darwin/amd64 12 | goos: [linux, windows, darwin] 13 | goarch: ["386", amd64] 14 | exclude: 15 | - goarch: "386" 16 | goos: darwin 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: wangyoucao577/go-release-action@v1.20 20 | env: 21 | RELEASE_TAG: ${{ github.event.release.tag_name }} 22 | BRANCH: ${{ github.ref }} 23 | COMMIT: ${{ github.sha }} 24 | with: 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | goos: ${{ matrix.goos }} 27 | goarch: ${{ matrix.goarch }} 28 | goversion: "https://dl.google.com/go/go1.16.5.linux-amd64.tar.gz" 29 | binary_name: "spinup-backend" 30 | ldflags: "-X 'github.com/spinup-host/spinup/build.Version=$RELEASE_TAG' -X 'github.com/spinup-host/spinup/build.FullCommit=$COMMIT' github.com/spinup-host/spinup/build.Branch=$BRANCH" 31 | extra_files: README.md CONTRIBUTING.md 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.service 3 | app.rsa 4 | app.rsa.pub 5 | spinup-host 6 | config.yaml 7 | testuser 8 | bin 9 | spinup 10 | prom_data 11 | grafana 12 | metastore.db 13 | prometheus.yml 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Spinup Host 2 | 3 | Thanks for your interest in improving the project! This document provides a step-by-step guide for general contributions to Spinup. 4 | 5 | ## Communications 6 | 7 | We have a slack, join using the invite [link](https://join.slack.com/t/spinuphost/shared_invite/zt-17mve4j4g-kf13SuKvGGnMSyeQDCoE9Q). 8 | 9 | ## Submitting a PR 10 | 11 | If you have a specific idea of a fix or update, follow these steps below to submit a PR: 12 | 13 | - [Contributing to Spinup Host](#contributing-to-spinup-host) 14 | - [Communications](#communications) 15 | - [Submitting a PR](#submitting-a-pr) 16 | - [Step 1: Make the change](#step-1-make-the-change) 17 | - [Step 2: Start Spinup locally](#step-2-start-spinup-locally) 18 | - [Step 3: Commit and push your changes](#step-3-commit-and-push-your-changes) 19 | - [Step 4: Create a pull request](#step-4-create-a-pull-request) 20 | - [Step 5: Get a code review](#step-5-get-a-code-review) 21 | 22 | ### Step 1: Make the change 23 | 24 | 1. Fork the Spinup repo, and then clone it: 25 | 26 | ```bash 27 | export user={your github profile name} 28 | git clone git@github.com:${user}/spinup.git 29 | ``` 30 | 31 | 2. Set your cloned local to track the upstream repository: 32 | 33 | ```bash 34 | cd spinup 35 | git remote add upstream https://github.com/spinup-host/spinup 36 | ``` 37 | 38 | 3. Disable pushing to upstream master: 39 | 40 | ```bash 41 | git remote set-url --push upstream no_push 42 | git remote -v 43 | ``` 44 | 45 | The output should look like: 46 | 47 | ```bash 48 | origin git@github.com:$(user)/spinup.git (fetch) 49 | origin git@github.com:$(user)/spinup.git (push) 50 | upstream https://github.com/spinup-host/spinup (fetch) 51 | upstream no_push (push) 52 | ``` 53 | 54 | 4. Get your local master up-to-date and create your working branch: 55 | 56 | ```bash 57 | git fetch upstream 58 | git checkout master 59 | git rebase upstream/master 60 | git checkout -b myfeature 61 | ``` 62 | 63 | ### Step 2: Compile and start the Spinup API server locally 64 | ```bash 65 | make run-api 66 | ``` 67 | NB: Run `make help` on your terminal to see the full list of make commands. 68 | 69 | ### Step 3: Hack and make your changes! 70 | 71 | ### Step 4: Commit, sync and test your changes 72 | 73 | 1. Run the following commands to keep your branch in sync: 74 | 75 | ```bash 76 | git fetch upstream 77 | git rebase upstream/master 78 | ``` 79 | 80 | 2 Run these commands to validate your changes. 81 | ```bash 82 | make test 83 | make checks 84 | ``` 85 | 86 | ### Making a Pull Request 87 | Pull request are welcome. For major changes, please open an issue first to discuss what you would like to do. 88 | 89 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS builder 2 | 3 | RUN set -ex && \ 4 | apk add --no-cache gcc musl-dev 5 | 6 | # Set necessary environmet variables needed for our image 7 | ENV GO111MODULE=on \ 8 | CGO_ENABLED=1 \ 9 | GOOS=linux \ 10 | GOARCH=amd64 \ 11 | SPINUP_PROJECT_DIR=/tmp/spinuplocal \ 12 | ARCHITECTURE=amd64 \ 13 | CF_AUTHORIZATION_TOKEN=replaceme \ 14 | CF_ZONE_ID=replaceme \ 15 | CLIENT_ID=replaceme \ 16 | CLIENT_SECRET=replaceme 17 | 18 | # Move to working directory /build 19 | WORKDIR /build 20 | 21 | # Copy and download dependency using go mod 22 | COPY go.mod . 23 | COPY go.sum . 24 | RUN go mod download 25 | 26 | # Copy the code into the container 27 | COPY . . 28 | 29 | # Build the application 30 | RUN go build -o main . 31 | 32 | # Move to /dist directory as the place for resulting binary folder 33 | WORKDIR /dist 34 | 35 | # Copy binary from build to main folder 36 | RUN cp /build/main . 37 | 38 | # Build a small image 39 | FROM docker/compose 40 | 41 | COPY --from=builder /dist/main / 42 | RUN mkdir /tmp/spinuplocal 43 | 44 | 45 | # Set necessary environmet variables needed for our image 46 | ENV GO111MODULE=on \ 47 | CGO_ENABLED=1 \ 48 | GOOS=linux \ 49 | GOARCH=amd64 \ 50 | SPINUP_PROJECT_DIR=/tmp/spinuplocal \ 51 | ARCHITECTURE=amd64 \ 52 | CF_AUTHORIZATION_TOKEN=replaceme \ 53 | CF_ZONE_ID=replaceme \ 54 | CLIENT_ID=replaceme \ 55 | CLIENT_SECRET=replaceme 56 | 57 | EXPOSE 4434 58 | 59 | # Command to run 60 | ENTRYPOINT ["/main"] 61 | 62 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOBIN ?= $(shell go env GOPATH)/bin 2 | BINARY_NAME ?= spinup 3 | VERSION ?= dev-unknown 4 | DOCKER_NETWORK ?= "spinup_services" 5 | 6 | SPINUP_BUILD_TAGS = -ldflags " \ 7 | -X 'github.com/spinup-host/spinup/build.Version=$(VERSION)' \ 8 | -X 'github.com/spinup-host/spinup/build.FullCommit=$(shell git rev-parse HEAD)' \ 9 | -X 'github.com/spinup-host/spinup/build.Branch=$(shell git symbolic-ref --short HEAD)' \ 10 | " 11 | 12 | GREEN := $(shell tput -Txterm setaf 2) 13 | YELLOW := $(shell tput -Txterm setaf 3) 14 | WHITE := $(shell tput -Txterm setaf 7) 15 | CYAN := $(shell tput -Txterm setaf 6) 16 | RESET := $(shell tput -Txterm sgr0) 17 | 18 | .PHONY: all test build 19 | 20 | all: help 21 | 22 | build: ## Build your project and put the output binary in out/bin/ 23 | go build $(SPINUP_BUILD_TAGS) -o bin/$(BINARY_NAME) . 24 | 25 | clean: ## Remove build related file 26 | rm -fr ./bin 27 | 28 | test: ## Run the tests of the project 29 | go test -v ./... $(OUTPUT_OPTIONS) 30 | 31 | test-coverage: ## Run the tests of the project and export the coverage 32 | go test -cover -covermode=count -coverprofile=profile.cov ./... 33 | go tool cover -func profile.cov 34 | 35 | format: 36 | goimports -local "github.com/spinup-host/spinup" -l -w ./ 37 | for f in $(git status -s | cut -f3 -d' '); do go fmt "$f"; done 38 | gci write --section Standard --section Default --section "Prefix(github.com/spinup-host/spinup)" . 39 | 40 | install-deps: 41 | go mod download 42 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.45.2 43 | go install github.com/vektra/mockery/v2@v2.14.0 44 | go install github.com/daixiang0/gci@v0.4.2 45 | go install github.com/go-swagger/go-swagger/cmd/swagger@v0.29.0 46 | @echo 'Dev dependencies have been installed. Run "export PATH=$$PATH/$$(go env GOPATH)/bin" to use installed binaries.' 47 | 48 | run-api: 49 | go run main.go start --api-only 50 | 51 | checks: ## Run all available checks and linters 52 | # golangci-lint run --enable-all # disable golangci-lint for now as it can get annoying 53 | 54 | docs: ## Generate OpenAPI docs 55 | swagger generate spec -o openapi/swagger.yaml --scan-models --exclude-deps 56 | 57 | serve-docs: 58 | swagger serve -F=swagger openapi/swagger.yaml 59 | 60 | stop-services: ## Removes all running containers in the Spinup network 61 | docker stop $(shell docker container ls --filter="network=${DOCKER_NETWORK}" -q --all) 62 | 63 | remove-services: ## Removes all running containers in the Spinup network 64 | docker rm $(shell docker container ls --filter="network=${DOCKER_NETWORK}" -q --all) 65 | 66 | 67 | help: ## Show make commands help. 68 | @echo '' 69 | @echo 'Usage:' 70 | @echo ' ${YELLOW}make${RESET} ${GREEN}${RESET}' 71 | @echo '' 72 | @echo 'Targets:' 73 | @awk 'BEGIN {FS = ":.*?## "} { \ 74 | if (/^[a-zA-Z_-]+:.*?##.*$$/) {printf " ${YELLOW}%-20s${GREEN}%s${RESET}\n", $$1, $$2} \ 75 | else if (/^## .*$$/) {printf " ${CYAN}%s${RESET}\n", substr($$1,4)} \ 76 | }' $(MAKEFILE_LIST) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SPINUP 2 | 3 | An open source alternative to [AWS RDS](https://aws.amazon.com/rds/), [Cloud SQL](https://cloud.google.com/sql). 4 | 5 | ## Architecture 6 | 7 | The idea is simple. Spinup creates multiple containers through docker-compose. 8 | Spinup can be deployed anywhere. System requirements are Go and docker-compose. Once this [issue](https://github.com/spinup-host/spinup/issues/45) has been fixed then you don't need to have Go installed. It can run on [Digital Ocean droplet](https://www.digitalocean.com/products/droplets/), [Azure Compute](https://azure.microsoft.com/en-us/product-categories/compute/), [Oracle Compute](https://www.oracle.com/cloud/compute/), [Raspberry Pi](https://www.raspberrypi.org/) etc. 9 | 10 | We are currently using Github Authentication. We should be able to support other authentication methods. 11 | 12 | Currently we only support Postgres dbms, but we should be able to support other open source databases like [MySQL](https://www.mysql.com/), [MariaDB](https://mariadb.org/) etc. 13 | 14 | ![architecture](architecture.png) 15 | ## Installation 16 | ### Linux 17 | To get started with Spinup on Linux: 18 | - Run the installation script using the command below: 19 | ```bash 20 | bash < <(curl -s https://raw.githubusercontent.com/spinup-host/spinup/main/scripts/install-spinup.sh) 21 | ``` 22 | - Add the Spinup installation directory (default is `$HOME/.local/spinup`) to your shell PATH to use Spinup from your terminal. 23 | - Start the Spinup servers (both API and frontend) by running: 24 | ```bash 25 | spinup start 26 | ``` 27 | - (Optional) If you want to use Github Authentication, create a project in [github](https://github.com). Next, export the project's client ID and Secret as environment variables 28 | ```bash 29 | export CLIENT_ID= 30 | export CLIENT_SECRET= 31 | ``` 32 | 33 | - (Optional) You can change the API_KEY value using enviornment variable. Default value is `spinup` 34 | ```bash 35 | export SPINUP_API_KEY= 36 | ``` 37 | 38 | ### Docker 39 | Spinup works by starting other (database and related) services in docker, but you can test-run Spinup itself as a docker container. 40 | To do this: 41 | - Clone this repository and enter into the directory. 42 | - Build the Spinup docker image using the `Dockerfile` in the root folder e.g., with: 43 | ``` 44 | docker build -t spinup/spinup .` 45 | ``` 46 | - Mount the `docker.sock` from your host into the Spinup container and start it as shown below: 47 | ``` 48 | docker run -v /var/run/docker.sock:/var/run/docker.sock -v ./path/to/config.yaml:/root/.local/spinup/config.yaml -p 4434:4434 id/spinup:latest start --api-only 49 | ``` 50 | The command above will start Spinup in API-only mode. 51 | 52 | NB: Mounting `docker.sock` gives Spinup root access to your host machine. 53 | 54 | ## Monitoring 55 | With monitoring enabled, Spinup will automatically setup monitoring services (Prometheus, Postgres Exporter, and Grafana) 56 | for you on startup. Every new database you add will automatically be added to postgres exporter for scraping and its metrics exposed in Prometheus/Grafana. 57 | 58 | To enable monitoring, set the `common.monitoring` field in your spinup config file to `true` (or add it if it doesn't exist). 59 | *Note that `common.monitoring` above implies the `monitoring` field in the `common` section in the YAML file* 60 | When monitoring is enabled, the monitoring services are started as follows: 61 | - Prometheus - http://localhost:9090 62 | - Grafana - http://localhost:9091 63 | - Postgres Exporter - http://localhost:9187 64 | 65 | Visit http://localhost:9091/explore to explore the provisioned Prometheus in Grafana, 66 | 67 | ## Backups 68 | ### Others 69 | 70 | **To create a private key** 71 | ``` 72 | visi@visis-MacBook-Pro spinup % openssl genrsa -out /${SPINUP_PROJECT_DIR}/app.rsa 4096 73 | Generating RSA private key, 4096 bit long modulus 74 | ...++ 75 | ...................++ 76 | e is 65537 (0x10001) 77 | ``` 78 | 79 | **To create a public key** 80 | ``` 81 | visi@visis-MacBook-Pro spinup % openssl rsa -in /${SPINUP_PROJECT_DIR}/app.rsa -pubout > /${SPINUP_PROJECT_DIR}/app.rsa.pub 82 | writing RSA key 83 | ``` 84 | **Create a config.yaml file** 85 | 86 | ``` 87 | common: 88 | ports: [ 89 | 5432, 5433, 5434, 5435, 5436, 5437 90 | ] 91 | architecture: amd64 92 | projectDir: 93 | client_id: 94 | client_secret: 95 | api_key: //if not using github authentication 96 | ``` 97 | **To run spinup** 98 | 99 | ```go run main.go``` 100 | 101 | #### Authentication 102 | We use JWT for verification. You need to have a private and public key that you can create using OpenSSL: 103 | 104 | On another terminal you can start the [dash](https://github.com/spinup-host/spinup-dash) to access the backend. 105 | 106 | To check the API endpoint: 107 | ``` 108 | curl -X POST http://localhost:4434/createservice \ 109 | -H "Content-Type: application/json" \ 110 | -H "Authorization: Bearer reaplaceyourtokenhere" \ 111 | --data '{ 112 | "userId": "viggy28", 113 | "db": { 114 | "type": "postgres", 115 | "name": "localtest", 116 | "username": "spinup", 117 | "password": "spinup", 118 | "memory": 6000, //In Megabytes 119 | "cpu": 2 120 | }, 121 | "version": {"maj":9,"min":6} 122 | }' 123 | ``` 124 | 125 | Once you created a cluster, you can connect using psql or any other postgres client 126 | 127 | ``` 128 | visi@visis-MacBook-Pro ~ % psql -h localhost -U postgres -p 129 | Password for user postgres: 130 | psql (9.6.18, server 13.3 (Debian 13.3-1.pgdg100+1)) 131 | WARNING: psql major version 9.6, server major version 13. 132 | Some psql features might not work. 133 | Type "help" for help. 134 | 135 | postgres=# \dt 136 | ``` 137 | 138 | # API Documentation 139 | The API documentation is autogenerated and saved in `openapi/swagger.yaml`. To view the documentaion on Swagger UI, run the command below from the project directory: 140 | ```bash 141 | make serve-docs 142 | ``` -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "go.uber.org/zap" 9 | 10 | "github.com/spinup-host/spinup/config" 11 | ) 12 | 13 | type ClusterHandler struct { 14 | svc clusterService 15 | logger *zap.Logger 16 | appConfig config.Configuration 17 | } 18 | 19 | func NewClusterHandler(clusterService clusterService, cfg config.Configuration, logger *zap.Logger) (ClusterHandler, error) { 20 | return ClusterHandler{ 21 | svc: clusterService, 22 | logger: logger, 23 | appConfig: cfg, 24 | }, nil 25 | } 26 | 27 | // respond converts its data parameter to JSON and send it as an HTTP response. 28 | func respond(statusCode int, w http.ResponseWriter, data interface{}) { 29 | if statusCode == http.StatusNoContent { 30 | w.WriteHeader(statusCode) 31 | return 32 | } 33 | 34 | // Convert the response value to JSON. 35 | jsonData, err := json.Marshal(data) 36 | if err != nil { 37 | log.Println(err) 38 | w.WriteHeader(http.StatusInternalServerError) 39 | return 40 | } 41 | 42 | w.Header().Set("Content-Type", "application/json") 43 | w.WriteHeader(statusCode) 44 | if _, err := w.Write(jsonData); err != nil { 45 | log.Println(err) 46 | w.WriteHeader(http.StatusInternalServerError) 47 | return 48 | } 49 | } 50 | 51 | func Hello(w http.ResponseWriter, req *http.Request) { 52 | respond(200, w, map[string]string{ 53 | "message": "Welcome to Spinup!", 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | ) 8 | 9 | const TestAppPort = 23455 10 | 11 | func createServer(ch ClusterHandler) *http.Server { 12 | addr := fmt.Sprintf(":%d", TestAppPort) 13 | 14 | router := http.NewServeMux() 15 | router.HandleFunc("/hello", Hello) 16 | router.HandleFunc("/createservice", ch.CreateCluster) 17 | router.HandleFunc("/listcluster", ch.ListCluster) 18 | router.HandleFunc("/cluster", ch.GetCluster) 19 | 20 | srv := &http.Server{ 21 | Addr: addr, 22 | Handler: router, 23 | } 24 | return srv 25 | } 26 | 27 | func executeRequest(srv *http.Server, req *http.Request) *httptest.ResponseRecorder { 28 | rr := httptest.NewRecorder() 29 | srv.Handler.ServeHTTP(rr, req) 30 | 31 | return rr 32 | } 33 | -------------------------------------------------------------------------------- /api/backup.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "go.uber.org/zap" 11 | 12 | "github.com/spinup-host/spinup/config" 13 | "github.com/spinup-host/spinup/internal/metastore" 14 | "github.com/spinup-host/spinup/utils" 15 | ) 16 | 17 | // createBackupRequest holds the parameters needed to create a backup 18 | type createBackupRequest struct { 19 | ClusterID string `json:"cluster_id"` 20 | Name string `json:"name"` 21 | ApiKeyID string `json:"api_key_id"` 22 | ApiKeySecret string `json:"api_key_secret"` 23 | BucketName string `json:"bucket_name"` 24 | } 25 | 26 | type BackupHandler struct { 27 | logger *zap.Logger 28 | appConfig config.Configuration 29 | backupService backupService 30 | } 31 | 32 | func NewBackupHandler(cfg config.Configuration, backupService backupService, logger *zap.Logger) BackupHandler { 33 | return BackupHandler{ 34 | logger: logger, 35 | appConfig: cfg, 36 | backupService: backupService, 37 | } 38 | } 39 | 40 | func (b BackupHandler) CreateBackup(w http.ResponseWriter, r *http.Request) { 41 | if (*r).Method != "POST" { 42 | respond(http.StatusMethodNotAllowed, w, map[string]string{"message": "invalid method"}) 43 | return 44 | } 45 | authHeader := r.Header.Get("Authorization") 46 | apiKeyHeader := r.Header.Get("x-api-key") 47 | var err error 48 | _, err = ValidateUser(b.appConfig, authHeader, apiKeyHeader) 49 | if err != nil { 50 | b.logger.Error("Failed to validate user", zap.Error(err)) 51 | respond(http.StatusUnauthorized, w, map[string]string{ 52 | "message": "Unauthorized", 53 | }) 54 | return 55 | } 56 | var s createBackupRequest 57 | byteArray, err := io.ReadAll(r.Body) 58 | if err != nil { 59 | utils.Logger.Error("failed to read request body", zap.Error(err)) 60 | respond(http.StatusInternalServerError, w, map[string]string{"message": "failed to read request body"}) 61 | return 62 | } 63 | err = json.Unmarshal(byteArray, &s) 64 | if err != nil { 65 | utils.Logger.Error("failed to parse request body", zap.Error(err)) 66 | respond(http.StatusInternalServerError, w, map[string]string{"message": "failed to parse request body"}) 67 | return 68 | } 69 | 70 | if err = backupDataValidation(s); err != nil { 71 | l := &logicError{} 72 | if errors.As(err, l) { 73 | respond(http.StatusBadRequest, w, map[string]string{"message": l.Error()}) 74 | } else { 75 | respond(http.StatusInternalServerError, w, map[string]string{"message": err.Error()}) 76 | } 77 | return 78 | } 79 | 80 | backupCfg := metastore.BackupConfig{ 81 | Dest: metastore.Destination{ 82 | Name: s.Name, 83 | BucketName: s.BucketName, 84 | ApiKeyID: s.ApiKeyID, 85 | ApiKeySecret: s.ApiKeySecret, 86 | }, 87 | } 88 | 89 | if err := b.backupService.CreateBackup(r.Context(), s.ClusterID, backupCfg); err != nil { 90 | b.logger.Error("failed to create backup", zap.Error(err)) 91 | respond(http.StatusInternalServerError, w, map[string]string{"message": err.Error()}) 92 | return 93 | } 94 | respond(http.StatusOK, w, map[string]string{"message": "successfully scheduled backup"}) 95 | } 96 | 97 | type logicError struct { 98 | err error 99 | } 100 | 101 | func (l logicError) Error() string { 102 | return fmt.Sprintf("logic error %v", l.err) 103 | } 104 | 105 | func backupDataValidation(s createBackupRequest) error { 106 | if s.ClusterID == "" { 107 | return errors.New("no cluster specified as a backup target") 108 | } 109 | 110 | if s.Name != "AWS" { 111 | return logicError{err: errors.New("destination other than AWS is not supported")} 112 | } 113 | if s.ApiKeyID == "" || s.ApiKeySecret == "" { 114 | return errors.New("api key id and api key secret is mandatory") 115 | } 116 | if s.BucketName == "" { 117 | return errors.New("bucket name is mandatory") 118 | } 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /api/cluster.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "io/fs" 9 | "net/http" 10 | 11 | "go.uber.org/zap" 12 | _ "modernc.org/sqlite" 13 | 14 | "github.com/spinup-host/spinup/internal/dockerservice" 15 | "github.com/spinup-host/spinup/internal/metastore" 16 | "github.com/spinup-host/spinup/misc" 17 | ) 18 | 19 | // Cluster is used to parse request from JSON payload 20 | // todo merge with metastore.ClusterInfo 21 | type Cluster struct { 22 | UserID string `json:"userId"` 23 | // one of arm64v8 or arm32v7 or amd64 24 | Architecture string `json:"architecture"` 25 | Db dbCluster `json:"db"` 26 | Version version `json:"version"` 27 | } 28 | 29 | type version struct { 30 | Maj uint `json:"maj"` 31 | Min uint `json:"min"` 32 | } 33 | type dbCluster struct { 34 | Name string `json:"name"` 35 | ID string `json:"id,omitempty"` 36 | Type string `json:"type"` 37 | Username string `json:"username"` 38 | Password string `json:"password"` 39 | 40 | Memory int64 `json:"memory,omitempty"` 41 | CPU int64 `json:"cpu,omitempty"` 42 | Monitoring string `json:"monitoring"` 43 | } 44 | 45 | // CreateCluster creates a new database with the provided parameters. 46 | func (c ClusterHandler) CreateCluster(w http.ResponseWriter, req *http.Request) { 47 | if (*req).Method != "POST" { 48 | respond(http.StatusMethodNotAllowed, w, map[string]string{"message": "Invalid Method"}) 49 | return 50 | } 51 | authHeader := req.Header.Get("Authorization") 52 | apiKeyHeader := req.Header.Get("x-api-key") 53 | var err error 54 | _, err = ValidateUser(c.appConfig, authHeader, apiKeyHeader) 55 | if err != nil { 56 | c.logger.Error("Failed to validate user", zap.Error(err)) 57 | respond(http.StatusUnauthorized, w, map[string]string{ 58 | "message": "Unauthorized", 59 | }) 60 | return 61 | } 62 | 63 | var s Cluster 64 | 65 | byteArray, err := io.ReadAll(req.Body) 66 | if err != nil { 67 | c.logger.Error("error reading request body", zap.Error(err)) 68 | respond(http.StatusInternalServerError, w, map[string]string{"message": "Error reading request body"}) 69 | return 70 | } 71 | err = json.Unmarshal(byteArray, &s) 72 | if err != nil { 73 | c.logger.Error("parsing request", zap.Error(err)) 74 | respond(http.StatusBadRequest, w, map[string]string{"message": "Error reading request body"}) 75 | return 76 | } 77 | 78 | if s.Db.Type != "postgres" { 79 | c.logger.Error("unsupported database type") 80 | respond(http.StatusBadRequest, w, map[string]string{"message": "Provided database type is not supported"}) 81 | return 82 | } 83 | port, err := misc.PortCheck(c.appConfig.Common.Ports[0], c.appConfig.Common.Ports[len(c.appConfig.Common.Ports)-1]) 84 | if err != nil { 85 | c.logger.Error("port issue", zap.Error(err)) 86 | respond(http.StatusInternalServerError, w, map[string]string{"message": "Could not find an open port"}) 87 | return 88 | } 89 | s.Architecture = c.appConfig.Common.Architecture 90 | 91 | cluster := metastore.ClusterInfo{ 92 | Architecture: s.Architecture, 93 | Type: s.Db.Type, 94 | Host: "localhost", 95 | Name: s.Db.Name, 96 | Username: s.Db.Username, 97 | Password: s.Db.Password, 98 | Port: port, 99 | MajVersion: int(s.Version.Maj), 100 | MinVersion: int(s.Version.Min), 101 | Monitoring: s.Db.Monitoring, 102 | } 103 | 104 | if cluster.MajVersion <= 9 { 105 | respond(http.StatusBadRequest, w, map[string]string{"message": "Unsupported Postgres version. Minimum supported major version is v9"}) 106 | return 107 | } 108 | if err := c.svc.CreateService(req.Context(), &cluster); err != nil { 109 | c.logger.Error("failed to add create service", zap.Error(err)) 110 | if errors.Is(err, dockerservice.ErrDuplicateContainerName) { 111 | respond(http.StatusBadRequest, w, map[string]string{"message": "container with provided name already exists"}) 112 | } else { 113 | respond(http.StatusBadRequest, w, map[string]string{"message": "failed to add service"}) 114 | } 115 | return 116 | } 117 | respond(http.StatusOK, w, cluster) 118 | return 119 | } 120 | 121 | func (c ClusterHandler) ListCluster(w http.ResponseWriter, req *http.Request) { 122 | if (*req).Method != "GET" { 123 | respond(http.StatusMethodNotAllowed, w, map[string]string{ 124 | "message": "Invalid Method"}) 125 | return 126 | } 127 | authHeader := req.Header.Get("Authorization") 128 | apiKeyHeader := req.Header.Get("x-api-key") 129 | var err error 130 | _, err = ValidateUser(c.appConfig, authHeader, apiKeyHeader) 131 | if err != nil { 132 | c.logger.Error("validating user", zap.Error(err)) 133 | respond(http.StatusUnauthorized, w, map[string]string{ 134 | "message": "unauthorized", 135 | }) 136 | return 137 | } 138 | clustersInfo, err := c.svc.ListClusters(req.Context()) 139 | if err != nil { 140 | c.logger.Error("failed to list clusters", zap.Error(err)) 141 | respond(http.StatusInternalServerError, w, map[string]string{ 142 | "message": "Failed to list clusters", 143 | }) 144 | return 145 | } 146 | respond(http.StatusOK, w, clustersInfo) 147 | return 148 | } 149 | 150 | func (c ClusterHandler) GetCluster(w http.ResponseWriter, r *http.Request) { 151 | if (*r).Method != "GET" { 152 | respond(http.StatusMethodNotAllowed, w, map[string]interface{}{ 153 | "message": "method not allowed", 154 | }) 155 | return 156 | } 157 | // todo (idoqo): move auth stuff to a "middleware" 158 | authHeader := r.Header.Get("Authorization") 159 | apiKeyHeader := r.Header.Get("x-api-key") 160 | var err error 161 | _, err = ValidateUser(c.appConfig, authHeader, apiKeyHeader) 162 | if err != nil { 163 | c.logger.Error("validating user", zap.Error(err)) 164 | respond(http.StatusInternalServerError, w, map[string]interface{}{ 165 | "message": "could not validate user", 166 | }) 167 | return 168 | } 169 | 170 | clusterId := r.URL.Query().Get("cluster_id") 171 | if clusterId == "" { 172 | respond(http.StatusBadRequest, w, map[string]interface{}{ 173 | "message": "cluster_id not present", 174 | }) 175 | return 176 | } 177 | 178 | ci, err := c.svc.GetClusterByID(r.Context(), clusterId) 179 | if errors.Is(err, fs.ErrNotExist) { 180 | c.logger.Error("no database file", zap.Error(err)) 181 | respond(http.StatusInternalServerError, w, map[string]interface{}{ 182 | "message": "sqlite database was not found", 183 | }) 184 | } 185 | 186 | if err == sql.ErrNoRows { 187 | respond(http.StatusNotFound, w, map[string]interface{}{ 188 | "message": "no cluster found with matching id", 189 | }) 190 | return 191 | } else if err != nil { 192 | c.logger.Error("getting cluster info") 193 | respond(http.StatusInternalServerError, w, map[string]interface{}{ 194 | "message": "could not get cluster details", 195 | }) 196 | return 197 | } 198 | 199 | respond(http.StatusOK, w, map[string]interface{}{ 200 | "data": ci, 201 | }) 202 | } 203 | -------------------------------------------------------------------------------- /api/cluster_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | "go.uber.org/zap" 10 | 11 | "github.com/spinup-host/spinup/config" 12 | "github.com/spinup-host/spinup/internal/metastore" 13 | "github.com/spinup-host/spinup/testutils" 14 | ) 15 | 16 | // cluster tests contain unit tests for cluster-related API endpoints. 17 | func TestListCluster(t *testing.T) { 18 | svc := &mockClusterService{} 19 | 20 | testClusters := []metastore.ClusterInfo{ 21 | { 22 | ID: 1, 23 | Name: "test_cluster_1", 24 | ClusterID: "test_cluster_1", 25 | }, 26 | } 27 | 28 | svc.On("ListClusters", mock.Anything).Return(testClusters, nil) 29 | 30 | loggerConfig := zap.NewProductionConfig() 31 | loggerConfig.OutputPaths = []string{"stdout"} 32 | logger, err := loggerConfig.Build() 33 | assert.NoError(t, err) 34 | 35 | appConfig := testutils.GetConfig() 36 | ch, err := NewClusterHandler(svc, appConfig, logger) 37 | server := createServer(ch) 38 | 39 | t.Run("fails for unauthenticated users", func(t *testing.T) { 40 | listRequest, err := http.NewRequest(http.MethodGet, "/listcluster", nil) 41 | assert.NoError(t, err) 42 | response := executeRequest(server, listRequest) 43 | assert.Equal(t, http.StatusUnauthorized, response.Code) 44 | }) 45 | 46 | } 47 | -------------------------------------------------------------------------------- /api/deps.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spinup-host/spinup/internal/metastore" 7 | ) 8 | 9 | // clusterService provides an interface for API handlers to manage clusters 10 | // 11 | //go:generate mockery --name=clusterService --case=snake --inpackage --testonly 12 | type clusterService interface { 13 | CreateService(ctx context.Context, info *metastore.ClusterInfo) error 14 | ListClusters(ctx context.Context) ([]metastore.ClusterInfo, error) 15 | GetClusterByID(ctx context.Context, clusterID string) (metastore.ClusterInfo, error) 16 | } 17 | 18 | type backupService interface { 19 | CreateBackup(ctx context.Context, clusterID string, backupConfig metastore.BackupConfig) error 20 | } 21 | -------------------------------------------------------------------------------- /api/jwt.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/rsa" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | "github.com/golang-jwt/jwt" 11 | "github.com/pkg/errors" 12 | 13 | "github.com/spinup-host/spinup/config" 14 | ) 15 | 16 | func stringToJWT(key *rsa.PrivateKey, text string) (string, error) { 17 | // Declare the expiration time of the token 18 | // here, we have kept it as 2 days 19 | log.Println("string to JWT:", text) 20 | expirationTime := time.Now().Add(48 * time.Hour) 21 | // Create the JWT claims, which includes the text and expiry time 22 | claims := &Claims{ 23 | Text: text, 24 | StandardClaims: jwt.StandardClaims{ 25 | // In JWT, the expiry time is expressed as unix milliseconds 26 | ExpiresAt: expirationTime.Unix(), 27 | }, 28 | } 29 | // Declare the token with the algorithm used for signing, and the claims 30 | token := jwt.NewWithClaims(jwt.SigningMethodPS512, claims) 31 | // Create the JWT string 32 | signedToken, err := token.SignedString(key) 33 | if err != nil { 34 | return "", err 35 | } 36 | return signedToken, nil 37 | } 38 | 39 | func ValidateToken(appConfig config.Configuration, authHeader string) (string, error) { 40 | splitToken := strings.Split(authHeader, "Bearer ") 41 | if len(splitToken) < 2 { 42 | return "", fmt.Errorf("cannot validate empty token") 43 | } 44 | reqToken := splitToken[1] 45 | userID, err := JWTToString(appConfig.VerifyKey, reqToken) 46 | if err != nil { 47 | return "", err 48 | } 49 | 50 | if userID == "" { 51 | return "", errors.New("user ID cannot be blank") 52 | } 53 | return userID, nil 54 | } 55 | 56 | // Claims is a struct that will be encoded to a JWT. 57 | // We add jwt.StandardClaims as an embedded type, to provide fields like expiry time 58 | type Claims struct { 59 | Text string `json:"text"` 60 | jwt.StandardClaims 61 | } 62 | 63 | func ValidateUser(appConfig config.Configuration, authHeader string, apiKeyHeader string) (string, error) { 64 | if authHeader == "" && apiKeyHeader == "" { 65 | return "", errors.New("no authorization keys found") 66 | } 67 | 68 | if apiKeyHeader != "" { 69 | if err := ValidateApiKey(appConfig, apiKeyHeader); err != nil { 70 | log.Printf("error validating api-key %v", apiKeyHeader) 71 | return "", errors.New("error validating api-key") 72 | } else { 73 | return "testuser", nil 74 | } 75 | } 76 | 77 | if authHeader != "" { 78 | if userId, err := ValidateToken(appConfig, authHeader); err != nil { 79 | log.Printf("error validating token %v", authHeader) 80 | return "", errors.New("error validating token") 81 | } else { 82 | return userId, nil 83 | } 84 | } 85 | 86 | return "testuser", errors.New("could not validate authentication headers") 87 | } 88 | 89 | func ValidateApiKey(appConfig config.Configuration, apiKeyHeader string) error { 90 | if apiKeyHeader != appConfig.Common.ApiKey { 91 | return errors.New("invalid api key") 92 | } 93 | return nil 94 | } 95 | 96 | func JWTToString(publicKey *rsa.PublicKey, tokenString string) (string, error) { 97 | keyFunc := func(t *jwt.Token) (interface{}, error) { 98 | return publicKey, nil 99 | } 100 | claims := &Claims{} 101 | log.Println("JWT to string:", tokenString) 102 | token, err := jwt.ParseWithClaims(tokenString, claims, keyFunc) 103 | if err != nil { 104 | return "", err 105 | } 106 | if !token.Valid { 107 | return "", errors.New("invalid token") 108 | } 109 | log.Println("claims:", claims.Text) 110 | return claims.Text, nil 111 | } 112 | -------------------------------------------------------------------------------- /api/jwt_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spinup-host/spinup/testutils" 7 | ) 8 | 9 | func TestValidateUser(t *testing.T) { 10 | cfg := testutils.GetConfig() 11 | cfg.Common.ApiKey = "test_api_key" 12 | t.Run("invalid user", func(t *testing.T) { 13 | msg, err := ValidateUser(cfg, "", "") 14 | validErrMsg := "no authorization keys found" 15 | if err.Error() != validErrMsg || msg != "" { 16 | t.Errorf("expected: %s ,found: %s ,userId: %s", validErrMsg, err.Error(), msg) 17 | } 18 | invalidApiKey := cfg.Common.ApiKey + "$" 19 | msg, err = ValidateUser(cfg, "", invalidApiKey) 20 | validErrMsg = "error validating api-key" 21 | if err.Error() != validErrMsg || msg != "" { 22 | t.Errorf("expected: %s ,found: %s ,userId: %s", validErrMsg, err.Error(), msg) 23 | } 24 | }) 25 | 26 | t.Run("valid user", func(t *testing.T) { 27 | userId, err := ValidateUser(cfg, "", cfg.Common.ApiKey) 28 | if err != nil || userId != "testuser" { 29 | t.Errorf("expected: testuser ,found: %s ,userId: %s", err.Error(), userId) 30 | } 31 | }) 32 | 33 | } 34 | -------------------------------------------------------------------------------- /api/mock_cluster_service_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.14.0. DO NOT EDIT. 2 | 3 | package api 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | 10 | metastore "github.com/spinup-host/spinup/internal/metastore" 11 | ) 12 | 13 | // mockClusterService is an autogenerated mock type for the clusterService type 14 | type mockClusterService struct { 15 | mock.Mock 16 | } 17 | 18 | // CreateService provides a mock function with given fields: ctx, info 19 | func (_m *mockClusterService) CreateService(ctx context.Context, info *metastore.ClusterInfo) error { 20 | ret := _m.Called(ctx, info) 21 | 22 | var r0 error 23 | if rf, ok := ret.Get(0).(func(context.Context, *metastore.ClusterInfo) error); ok { 24 | r0 = rf(ctx, info) 25 | } else { 26 | r0 = ret.Error(0) 27 | } 28 | 29 | return r0 30 | } 31 | 32 | // GetClusterByID provides a mock function with given fields: ctx, clusterID 33 | func (_m *mockClusterService) GetClusterByID(ctx context.Context, clusterID string) (metastore.ClusterInfo, error) { 34 | ret := _m.Called(ctx, clusterID) 35 | 36 | var r0 metastore.ClusterInfo 37 | if rf, ok := ret.Get(0).(func(context.Context, string) metastore.ClusterInfo); ok { 38 | r0 = rf(ctx, clusterID) 39 | } else { 40 | r0 = ret.Get(0).(metastore.ClusterInfo) 41 | } 42 | 43 | var r1 error 44 | if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { 45 | r1 = rf(ctx, clusterID) 46 | } else { 47 | r1 = ret.Error(1) 48 | } 49 | 50 | return r0, r1 51 | } 52 | 53 | // ListClusters provides a mock function with given fields: ctx 54 | func (_m *mockClusterService) ListClusters(ctx context.Context) ([]metastore.ClusterInfo, error) { 55 | ret := _m.Called(ctx) 56 | 57 | var r0 []metastore.ClusterInfo 58 | if rf, ok := ret.Get(0).(func(context.Context) []metastore.ClusterInfo); ok { 59 | r0 = rf(ctx) 60 | } else { 61 | if ret.Get(0) != nil { 62 | r0 = ret.Get(0).([]metastore.ClusterInfo) 63 | } 64 | } 65 | 66 | var r1 error 67 | if rf, ok := ret.Get(1).(func(context.Context) error); ok { 68 | r1 = rf(ctx) 69 | } else { 70 | r1 = ret.Error(1) 71 | } 72 | 73 | return r0, r1 74 | } 75 | 76 | type mockConstructorTestingTnewMockClusterService interface { 77 | mock.TestingT 78 | Cleanup(func()) 79 | } 80 | 81 | // newMockClusterService creates a new instance of mockClusterService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 82 | func newMockClusterService(t mockConstructorTestingTnewMockClusterService) *mockClusterService { 83 | mock := &mockClusterService{} 84 | mock.Mock.Test(t) 85 | 86 | t.Cleanup(func() { mock.AssertExpectations(t) }) 87 | 88 | return mock 89 | } 90 | -------------------------------------------------------------------------------- /api/streamlogs.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "strconv" 8 | "text/template" 9 | "time" 10 | 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | const ( 15 | // Time allowed to write the file to the client. 16 | writeWait = 10 * time.Second 17 | 18 | // Time allowed to read the next pong message from the client. 19 | pongWait = 60 * time.Second 20 | 21 | // Send pings to client with this period. Must be less than pongWait. 22 | pingPeriod = (pongWait * 9) / 10 23 | 24 | // Poll file for changes with this period. 25 | filePeriod = 10 * time.Second 26 | ) 27 | 28 | var ( 29 | homeTempl = template.Must(template.New("").Parse(homeHTML)) 30 | filename = "/var/lib/docker/containers/1967dededef67f90df186c7569ccfa5d7d6828447bd47c4c48657c837990943a/1967dededef67f90df186c7569ccfa5d7d6828447bd47c4c48657c837990943a-json.log" 31 | upgrader = websocket.Upgrader{ 32 | ReadBufferSize: 1024, 33 | WriteBufferSize: 1024, 34 | } 35 | ) 36 | 37 | func readFileIfModified(lastMod time.Time) ([]byte, time.Time, error) { 38 | fi, err := os.Stat(filename) 39 | if err != nil { 40 | return nil, lastMod, err 41 | } 42 | if !fi.ModTime().After(lastMod) { 43 | return nil, lastMod, nil 44 | } 45 | p, err := os.ReadFile(filename) 46 | if err != nil { 47 | return nil, fi.ModTime(), err 48 | } 49 | return p, fi.ModTime(), nil 50 | } 51 | 52 | func reader(ws *websocket.Conn) { 53 | defer ws.Close() 54 | ws.SetReadLimit(512) 55 | ws.SetReadDeadline(time.Now().Add(pongWait)) 56 | ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(pongWait)); return nil }) 57 | for { 58 | _, _, err := ws.ReadMessage() 59 | if err != nil { 60 | break 61 | } 62 | } 63 | } 64 | 65 | func writer(ws *websocket.Conn, lastMod time.Time) { 66 | lastError := "" 67 | pingTicker := time.NewTicker(pingPeriod) 68 | fileTicker := time.NewTicker(filePeriod) 69 | defer func() { 70 | pingTicker.Stop() 71 | fileTicker.Stop() 72 | ws.Close() 73 | }() 74 | for { 75 | select { 76 | case <-fileTicker.C: 77 | var p []byte 78 | var err error 79 | 80 | p, lastMod, err = readFileIfModified(lastMod) 81 | 82 | if err != nil { 83 | if s := err.Error(); s != lastError { 84 | lastError = s 85 | p = []byte(lastError) 86 | } 87 | } else { 88 | lastError = "" 89 | } 90 | 91 | if p != nil { 92 | ws.SetWriteDeadline(time.Now().Add(writeWait)) 93 | if err := ws.WriteMessage(websocket.TextMessage, p); err != nil { 94 | return 95 | } 96 | } 97 | case <-pingTicker.C: 98 | ws.SetWriteDeadline(time.Now().Add(writeWait)) 99 | if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil { 100 | return 101 | } 102 | } 103 | } 104 | } 105 | 106 | func StreamLogs(w http.ResponseWriter, r *http.Request) { 107 | ws, err := upgrader.Upgrade(w, r, nil) 108 | if err != nil { 109 | if _, ok := err.(websocket.HandshakeError); !ok { 110 | log.Println(err) 111 | } 112 | return 113 | } 114 | 115 | var lastMod time.Time 116 | if n, err := strconv.ParseInt(r.FormValue("lastMod"), 16, 64); err == nil { 117 | lastMod = time.Unix(0, n) 118 | } 119 | 120 | go writer(ws, lastMod) 121 | reader(ws) 122 | } 123 | 124 | func Logs(w http.ResponseWriter, r *http.Request) { 125 | if r.URL.Path != "/logs" { 126 | respond(http.StatusNotFound, w, map[string]string{ 127 | "message": "Not found", 128 | }) 129 | return 130 | } 131 | if r.Method != "GET" { 132 | respond(http.StatusMethodNotAllowed, w, map[string]string{ 133 | "message": "method not allowed", 134 | }) 135 | return 136 | } 137 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 138 | p, lastMod, err := readFileIfModified(time.Time{}) 139 | if err != nil { 140 | p = []byte(err.Error()) 141 | lastMod = time.Unix(0, 0) 142 | } 143 | v := struct { 144 | Host string 145 | Data string 146 | LastMod string 147 | }{ 148 | r.Host, 149 | string(p), 150 | strconv.FormatInt(lastMod.UnixNano(), 16), 151 | } 152 | homeTempl.Execute(w, &v) 153 | } 154 | 155 | const homeHTML = ` 156 | 157 | 158 | WebSocket Example 159 | 160 | 161 |
{{.Data}}
162 | 175 | 176 | 177 | ` 178 | -------------------------------------------------------------------------------- /api/userauth.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rsa" 6 | "encoding/json" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | type user struct { 12 | Username string `json:"login"` 13 | AvatarURL string `json:"avatar_url"` 14 | Name string `json:"name"` 15 | Token string `json:"token"` 16 | JWTToken string `json:"jwttoken"` 17 | } 18 | 19 | type githubAccess struct { 20 | AccessToken string `json:"access_token"` 21 | TokenType string `json:"token_type"` 22 | Scope string `json:"scope"` 23 | } 24 | 25 | type GithubAuthHandler struct { 26 | clientID string 27 | clientSecret string 28 | privateKey *rsa.PrivateKey 29 | } 30 | 31 | func NewGithubAuthHandler(key *rsa.PrivateKey, clientID, clientSecret string) GithubAuthHandler { 32 | return GithubAuthHandler{ 33 | clientID: clientID, 34 | clientSecret: clientSecret, 35 | privateKey: key, 36 | } 37 | } 38 | 39 | func (g GithubAuthHandler) GithubAuth(w http.ResponseWriter, r *http.Request) { 40 | if r.Method != "POST" { 41 | respond(http.StatusMethodNotAllowed, w, map[string]string{ 42 | "message": "Invalid Method", 43 | }) 44 | return 45 | } 46 | type userAuth struct { 47 | Code string `json:"code"` 48 | } 49 | log.Println("req::", r.Body) 50 | var ua userAuth 51 | // TODO: format this to include best practices https://www.alexedwards.net/blog/how-to-properly-parse-a-json-request-body 52 | err := json.NewDecoder(r.Body).Decode(&ua) 53 | if err != nil { 54 | respond(http.StatusBadRequest, w, map[string]string{ 55 | "message": err.Error(), 56 | }) 57 | return 58 | } 59 | log.Println("ua", ua) 60 | 61 | log.Println("req::", r.Body) 62 | requestBodyMap := map[string]string{"client_id": g.clientID, "client_secret": g.clientSecret, "code": ua.Code} 63 | requestBodyJSON, err := json.Marshal(requestBodyMap) 64 | if err != nil { 65 | log.Printf("ERROR: marshalling github auth %v", requestBodyMap) 66 | respond(http.StatusInternalServerError, w, map[string]string{ 67 | "message": "Internal Server Error", 68 | }) 69 | return 70 | } 71 | req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(requestBodyJSON)) 72 | req.Header.Add("Accept", "application/json") 73 | req.Header.Add("Content-Type", "application/json") 74 | req.Header.Add("Access-Control-Allow-Origin", "*") 75 | client := http.Client{} 76 | res, err := client.Do(req) 77 | if err != nil { 78 | respond(http.StatusInternalServerError, w, map[string]string{ 79 | "message": err.Error(), 80 | }) 81 | return 82 | } 83 | defer res.Body.Close() 84 | if res.StatusCode != 200 { 85 | log.Printf("ERROR: likely user code is invalid") 86 | // http.Error(w, "ERROR: likely user code is invalid", http.StatusInternalServerError) 87 | // return 88 | } 89 | var ghAccessToken githubAccess 90 | err = json.NewDecoder(res.Body).Decode(&ghAccessToken) 91 | if err != nil { 92 | respond(http.StatusInternalServerError, w, map[string]string{ 93 | "message": err.Error(), 94 | }) 95 | return 96 | } 97 | githubUserDataURL := "https://api.github.com/user" 98 | req, err = http.NewRequest("GET", githubUserDataURL, nil) 99 | req.Header.Add("Authorization", "token "+ghAccessToken.AccessToken) 100 | client = http.Client{} 101 | res, err = client.Do(req) 102 | if err != nil { 103 | respond(http.StatusInternalServerError, w, map[string]string{ 104 | "message": err.Error(), 105 | }) 106 | return 107 | } 108 | defer res.Body.Close() 109 | // TODO: Do we need token field? Token is meant for the backend to communicate to Github 110 | var u user 111 | err = json.NewDecoder(res.Body).Decode(&u) 112 | if err != nil { 113 | respond(http.StatusInternalServerError, w, map[string]string{ 114 | "message": err.Error(), 115 | }) 116 | return 117 | } 118 | JWTToken, err := stringToJWT(g.privateKey, u.Username) 119 | if err != nil { 120 | respond(http.StatusInternalServerError, w, map[string]string{ 121 | "message": err.Error(), 122 | }) 123 | return 124 | } 125 | u.JWTToken = JWTToken 126 | userJSON, err := json.Marshal(u) 127 | if err != nil { 128 | respond(http.StatusInternalServerError, w, map[string]string{ 129 | "message": err.Error(), 130 | }) 131 | return 132 | } 133 | w.WriteHeader(http.StatusOK) 134 | w.Write(userJSON) 135 | } 136 | 137 | func (c ClusterHandler) AltAuth(w http.ResponseWriter, r *http.Request) { 138 | if r.Method != "POST" { 139 | http.Error(w, "Invalid Method ", http.StatusMethodNotAllowed) 140 | return 141 | } 142 | apiKeyHeader := r.Header.Get("x-api-key") 143 | log.Println("inside altauth") 144 | _, err := ValidateUser(c.appConfig, "", apiKeyHeader) 145 | if err != nil { 146 | http.Error(w, err.Error(), http.StatusUnauthorized) 147 | return 148 | } 149 | w.Write([]byte("Valid Api Key")) 150 | w.WriteHeader(http.StatusOK) 151 | 152 | } 153 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spinup-host/spinup/33f5ae28499d11be3f69a2f7c330cd00867cd649/architecture.png -------------------------------------------------------------------------------- /build/build.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | var ( 4 | // Version is the version number/tag assigned to this build 5 | Version = "dev-unknown" 6 | 7 | // FullCommit is the full git commit SHA for this build 8 | FullCommit = "" 9 | 10 | // Branch is the branch or tag name for this build. 11 | Branch = "" 12 | ) 13 | 14 | // Info wraps the version/build metadata 15 | type Info struct { 16 | Version string 17 | Commit string 18 | Branch string 19 | } 20 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | common: 2 | ports: [ 3 | 5432, 5433, 5434, 5435, 5436, 5437 4 | ] 5 | db_metric_ports: [ 6 | 55432, 55433, 55434, 55435, 55436, 55437 7 | ] 8 | architecture: amd64 9 | projectDir: 10 | client_id: #optional 11 | client_secret: #optional 12 | api_key: #if not using github authentication -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "crypto/rsa" 5 | ) 6 | 7 | const ( 8 | DefaultNetworkName = "spinup_services" 9 | ) 10 | 11 | type Configuration struct { 12 | Common struct { 13 | Architecture string `yaml:"architecture"` 14 | ProjectDir string `yaml:"projectDir"` 15 | Ports []int `yaml:"ports"` 16 | ClientID string `yaml:"client_id"` 17 | ClientSecret string `yaml:"client_secret"` 18 | ApiKey string `yaml:"api_key"` 19 | LogDir string `yaml:"log_dir"` 20 | LogFile string `yaml:"log_file"` 21 | Monitoring bool `yaml:"monitoring"` 22 | } `yaml:"common"` 23 | VerifyKey *rsa.PublicKey 24 | SignKey *rsa.PrivateKey 25 | UserID string 26 | PromConfig PrometheusConfig `yaml:"prom_config"` 27 | } 28 | 29 | type PrometheusConfig struct { 30 | Port int `yaml:"port"` 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/spinup-host/spinup 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/Microsoft/go-winio v0.6.0 // indirect 7 | github.com/docker/distribution v2.7.1+incompatible // indirect 8 | github.com/docker/docker v23.0.1+incompatible 9 | github.com/docker/go-connections v0.4.0 10 | github.com/docker/go-units v0.4.0 // indirect 11 | github.com/go-chi/chi/v5 v5.0.8 12 | github.com/gogo/protobuf v1.3.2 // indirect 13 | github.com/golang-jwt/jwt v3.2.1+incompatible 14 | github.com/google/uuid v1.3.0 15 | github.com/gorilla/websocket v1.5.0 16 | github.com/kr/text v0.2.0 // indirect 17 | github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect 18 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect 19 | github.com/morikuni/aec v1.0.0 // indirect 20 | github.com/opencontainers/image-spec v1.1.0-rc1 // indirect 21 | github.com/pkg/errors v0.9.1 22 | github.com/prometheus/client_golang v1.14.0 23 | github.com/robfig/cron/v3 v3.0.1 24 | github.com/rs/cors v1.9.0 25 | github.com/spf13/cobra v1.7.0 26 | github.com/stretchr/testify v1.8.4 27 | go.uber.org/atomic v1.9.0 // indirect 28 | go.uber.org/goleak v1.1.12 // indirect 29 | go.uber.org/multierr v1.8.0 30 | go.uber.org/zap v1.24.0 31 | golang.org/x/net v0.0.0-20220921203646-d300de134e69 // indirect 32 | golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect 33 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 34 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 35 | gopkg.in/yaml.v3 v3.0.1 36 | gotest.tools/v3 v3.0.3 // indirect 37 | modernc.org/sqlite v1.25.0 38 | ) 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 8 | cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= 9 | cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= 10 | cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= 11 | cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= 12 | cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= 13 | cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= 14 | cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= 15 | cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= 16 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 17 | cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= 18 | cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= 19 | cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= 20 | cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= 21 | cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= 22 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 23 | cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= 24 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 25 | cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= 26 | cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= 27 | cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= 28 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 29 | cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= 30 | cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= 31 | cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= 32 | cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= 33 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 34 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= 35 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 36 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 37 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 38 | github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= 39 | github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= 40 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 41 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 42 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 43 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 44 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= 45 | github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= 46 | github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 47 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 48 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 49 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 50 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 51 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 52 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 53 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= 54 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 55 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 56 | github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= 57 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 58 | github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= 59 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 60 | github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 61 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 62 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 63 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 64 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 65 | github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 66 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 67 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 68 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 69 | github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= 70 | github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 71 | github.com/docker/docker v23.0.1+incompatible h1:vjgvJZxprTTE1A37nm+CLNAdwu6xZekyoiVlUZEINcY= 72 | github.com/docker/docker v23.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 73 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 74 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 75 | github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= 76 | github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 77 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 78 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 79 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 80 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 81 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 82 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 83 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 84 | github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= 85 | github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 86 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 87 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 88 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 89 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 90 | github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 91 | github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 92 | github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= 93 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 94 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 95 | github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 96 | github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 97 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 98 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 99 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 100 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 101 | github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= 102 | github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 103 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 104 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 105 | github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 106 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 107 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 108 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 109 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 110 | github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 111 | github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 112 | github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 113 | github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 114 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 115 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 116 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 117 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 118 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 119 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 120 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 121 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 122 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 123 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 124 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 125 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 126 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 127 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 128 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 129 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 130 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 131 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 132 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 133 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 134 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 135 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 136 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 137 | github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 138 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 139 | github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 140 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 141 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 142 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 143 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 144 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 145 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 146 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 147 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 148 | github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 149 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 150 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 151 | github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 152 | github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 153 | github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 154 | github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 155 | github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 156 | github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= 157 | github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= 158 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 159 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 160 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 161 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 162 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 163 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 164 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 165 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 166 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 167 | github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 168 | github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= 169 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 170 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 171 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 172 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 173 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 174 | github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 175 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 176 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 177 | github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= 178 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 179 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 180 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 181 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 182 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 183 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 184 | github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 185 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 186 | github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 187 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 188 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 189 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 190 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 191 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 192 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 193 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 194 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 195 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 196 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 197 | github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= 198 | github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 199 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 200 | github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= 201 | github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= 202 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= 203 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= 204 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 205 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 206 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 207 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 208 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 209 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 210 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 211 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 212 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 213 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 214 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 215 | github.com/opencontainers/image-spec v1.1.0-rc1 h1:lfG+OTa7V8PD3PKvkocSG9KAcA9MANqJn53m31Fvwkc= 216 | github.com/opencontainers/image-spec v1.1.0-rc1/go.mod h1:3OVijpioIKYWTqjiG0zfF6wvoJ4fAXGbjdZuI2NgsRQ= 217 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 218 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 219 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 220 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 221 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 222 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 223 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 224 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 225 | github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= 226 | github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= 227 | github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= 228 | github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= 229 | github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= 230 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 231 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 232 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 233 | github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 234 | github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= 235 | github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= 236 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 237 | github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= 238 | github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= 239 | github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= 240 | github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE= 241 | github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= 242 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 243 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 244 | github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= 245 | github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= 246 | github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= 247 | github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= 248 | github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= 249 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 250 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 251 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 252 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 253 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 254 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 255 | github.com/rs/cors v1.9.0 h1:l9HGsTsHJcvW14Nk7J9KFz8bzeAWXn3CG6bgt7LsrAE= 256 | github.com/rs/cors v1.9.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 257 | github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= 258 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 259 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 260 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 261 | github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 262 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 263 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 264 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 265 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 266 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 267 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 268 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 269 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 270 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 271 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 272 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 273 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 274 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 275 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 276 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 277 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 278 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 279 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 280 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 281 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 282 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 283 | github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 284 | github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 285 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 286 | github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 287 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 288 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 289 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 290 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 291 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 292 | go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 293 | go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 294 | go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 295 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 296 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 297 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 298 | go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 299 | go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= 300 | go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 301 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 302 | go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= 303 | go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= 304 | go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= 305 | go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= 306 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 307 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 308 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 309 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 310 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 311 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 312 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 313 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 314 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 315 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 316 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 317 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 318 | golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 319 | golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 320 | golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 321 | golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= 322 | golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= 323 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 324 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 325 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 326 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 327 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 328 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 329 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 330 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 331 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 332 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= 333 | golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 334 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 335 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 336 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 337 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 338 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 339 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 340 | golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 341 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 342 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 343 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 344 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= 345 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 346 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 347 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 348 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 349 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 350 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 351 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 352 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 353 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 354 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 355 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 356 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 357 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 358 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 359 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 360 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 361 | golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 362 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 363 | golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 364 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 365 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 366 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 367 | golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 368 | golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 369 | golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 370 | golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 371 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 372 | golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 373 | golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 374 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 375 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 376 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 377 | golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 378 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 379 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 380 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 381 | golang.org/x/net v0.0.0-20220921203646-d300de134e69 h1:hUJpGDpnfwdJW8iNypFjmSY0sCBEL+spFTZ2eO+Sfps= 382 | golang.org/x/net v0.0.0-20220921203646-d300de134e69/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 383 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 384 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 385 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 386 | golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 387 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 388 | golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 389 | golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= 390 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 391 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 392 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 393 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 394 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 395 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 396 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 397 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 398 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 399 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 400 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 401 | golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 402 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 403 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 404 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 405 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 406 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 407 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 408 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 409 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 410 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 411 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 412 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 413 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 414 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 415 | golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 416 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 417 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 418 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 419 | golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 420 | golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 421 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 422 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 423 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 424 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 425 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 426 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 427 | golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 428 | golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 429 | golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 430 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 431 | golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 432 | golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 433 | golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 434 | golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 435 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 436 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 437 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 438 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 439 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 440 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 441 | golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 442 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 443 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 444 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 445 | golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 446 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 447 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 448 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 449 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 450 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 451 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 452 | golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc= 453 | golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 454 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 455 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 456 | golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 457 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 458 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 459 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 460 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 461 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 462 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 463 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 464 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 465 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 466 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= 467 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 468 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 469 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 470 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 471 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 472 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 473 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 474 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 475 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 476 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 477 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 478 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 479 | golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 480 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 481 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 482 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 483 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 484 | golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 485 | golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 486 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 487 | golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 488 | golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 489 | golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 490 | golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 491 | golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 492 | golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 493 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 494 | golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 495 | golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 496 | golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 497 | golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 498 | golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 499 | golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 500 | golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 501 | golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= 502 | golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 503 | golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 504 | golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 505 | golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 506 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 507 | golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 508 | golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 509 | golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 510 | golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 511 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 512 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 513 | golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= 514 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 515 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 516 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 517 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 518 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 519 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 520 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 521 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 522 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 523 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 524 | google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 525 | google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 526 | google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 527 | google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 528 | google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 529 | google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 530 | google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 531 | google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 532 | google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 533 | google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= 534 | google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= 535 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 536 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 537 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 538 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 539 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 540 | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 541 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 542 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 543 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 544 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 545 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 546 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 547 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 548 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 549 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 550 | google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 551 | google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 552 | google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 553 | google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 554 | google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 555 | google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= 556 | google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 557 | google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 558 | google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 559 | google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 560 | google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 561 | google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 562 | google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 563 | google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 564 | google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= 565 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 566 | google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= 567 | google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 568 | google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 569 | google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 570 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 571 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 572 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 573 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 574 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 575 | google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 576 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 577 | google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 578 | google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= 579 | google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= 580 | google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 581 | google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 582 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 583 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 584 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 585 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 586 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 587 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 588 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 589 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 590 | google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 591 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 592 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 593 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 594 | google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= 595 | google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 596 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 597 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 598 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 599 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 600 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 601 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 602 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 603 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 604 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 605 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 606 | gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 607 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 608 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 609 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 610 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 611 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 612 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 613 | gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= 614 | gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= 615 | gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= 616 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 617 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 618 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 619 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 620 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 621 | honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 622 | honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 623 | lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= 624 | lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= 625 | lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= 626 | modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= 627 | modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= 628 | modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= 629 | modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI= 630 | modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g= 631 | modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= 632 | modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= 633 | modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= 634 | modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= 635 | modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= 636 | modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= 637 | modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA= 638 | modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= 639 | modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= 640 | modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= 641 | modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= 642 | modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak= 643 | modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= 644 | modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 645 | modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 646 | modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 647 | modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 648 | modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o= 649 | modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 650 | modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 651 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 652 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 653 | modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA= 654 | modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU= 655 | modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= 656 | modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= 657 | modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= 658 | modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c= 659 | modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= 660 | modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 661 | modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY= 662 | modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE= 663 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 664 | rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= 665 | rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= 666 | -------------------------------------------------------------------------------- /internal/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/spinup-host/spinup/build" 9 | ) 10 | 11 | var rootCmd = &cobra.Command{ 12 | Use: "spinup", 13 | Short: "Spinup CLI", 14 | TraverseChildren: true, 15 | } 16 | 17 | func Execute(ctx context.Context, buildInfo build.Info) error { 18 | rootCmd.AddCommand(versionCmd(buildInfo)) 19 | rootCmd.AddCommand(startCmd()) 20 | 21 | return rootCmd.ExecuteContext(ctx) 22 | } 23 | -------------------------------------------------------------------------------- /internal/cmd/start.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "math/rand" 9 | "net" 10 | "net/http" 11 | "os" 12 | "os/exec" 13 | "os/signal" 14 | "path" 15 | "path/filepath" 16 | "strings" 17 | "syscall" 18 | "time" 19 | 20 | "github.com/go-chi/chi/v5" 21 | "github.com/go-chi/chi/v5/middleware" 22 | "github.com/golang-jwt/jwt" 23 | "github.com/rs/cors" 24 | "github.com/spf13/cobra" 25 | "go.uber.org/zap" 26 | "gopkg.in/yaml.v3" 27 | 28 | "github.com/spinup-host/spinup/api" 29 | "github.com/spinup-host/spinup/config" 30 | "github.com/spinup-host/spinup/internal/dockerservice" 31 | "github.com/spinup-host/spinup/internal/metastore" 32 | "github.com/spinup-host/spinup/internal/monitor" 33 | "github.com/spinup-host/spinup/internal/service" 34 | "github.com/spinup-host/spinup/metrics" 35 | "github.com/spinup-host/spinup/utils" 36 | ) 37 | 38 | var ( 39 | cfgFile string 40 | uiPath string 41 | apiOnly bool 42 | 43 | apiPort = ":4434" 44 | uiPort = ":3000" 45 | 46 | monitorRuntime *monitor.Runtime 47 | appConfig config.Configuration 48 | ) 49 | 50 | func apiHandler() http.Handler { 51 | dockerClient, err := dockerservice.NewDocker(config.DefaultNetworkName) 52 | if err != nil { 53 | utils.Logger.Error("could not create docker client", zap.Error(err)) 54 | } 55 | projectDir := filepath.Join(appConfig.Common.ProjectDir, "metastore.db") 56 | db, err := metastore.NewDb(projectDir) 57 | if err != nil { 58 | utils.Logger.Fatal("unable to setup sqlite database", zap.Error(err)) 59 | } 60 | 61 | clusterService := service.NewService(dockerClient, db, monitorRuntime, utils.Logger, appConfig) 62 | 63 | ch, err := api.NewClusterHandler(clusterService, appConfig, utils.Logger) 64 | if err != nil { 65 | utils.Logger.Fatal("unable to create NewClusterHandler") 66 | } 67 | mh, err := metrics.NewMetricsHandler(appConfig) 68 | if err != nil { 69 | utils.Logger.Fatal("unable to create NewClusterHandler") 70 | } 71 | 72 | backupService := service.NewBackupService(db, dockerClient, utils.Logger) 73 | bh := api.NewBackupHandler(appConfig, backupService, utils.Logger) 74 | githubHandler := api.NewGithubAuthHandler(appConfig.SignKey, appConfig.Common.ClientID, appConfig.Common.ClientSecret) 75 | 76 | rand.Seed(time.Now().UnixNano()) 77 | mux := http.NewServeMux() 78 | mux.HandleFunc("/hello", api.Hello) 79 | mux.HandleFunc("/createservice", ch.CreateCluster) 80 | mux.HandleFunc("/githubAuth", githubHandler.GithubAuth) 81 | mux.HandleFunc("/logs", api.Logs) 82 | mux.HandleFunc("/streamlogs", api.StreamLogs) 83 | mux.HandleFunc("/listcluster", ch.ListCluster) 84 | mux.HandleFunc("/cluster", ch.GetCluster) 85 | mux.HandleFunc("/metrics", mh.ServeHTTP) 86 | mux.HandleFunc("/createbackup", bh.CreateBackup) 87 | mux.HandleFunc("/altauth", ch.AltAuth) 88 | c := cors.New(cors.Options{ 89 | AllowedHeaders: []string{"authorization", "content-type", "x-api-key"}, 90 | }) 91 | 92 | return c.Handler(mux) 93 | } 94 | 95 | func startCmd() *cobra.Command { 96 | sc := &cobra.Command{ 97 | Use: "start", 98 | Short: "start the spinup API and frontend servers", 99 | Run: func(cmd *cobra.Command, args []string) { 100 | utils.InitializeLogger("", "") 101 | if !isDockerdRunning(context.Background()) { 102 | log.Fatalf("FATAL: docker daemon is not running. Start docker daemon") 103 | } 104 | log.Println(fmt.Sprintf("INFO: Using config file: %s", cfgFile)) 105 | if err := validateConfig(cfgFile); err != nil { 106 | log.Fatalf("FATAL: failed to validate config: %v", err) 107 | } 108 | log.Println("INFO: Initial Validations successful") 109 | utils.InitializeLogger(appConfig.Common.LogDir, appConfig.Common.LogFile) 110 | 111 | dockerClient, err := dockerservice.NewDocker(config.DefaultNetworkName) 112 | if err != nil { 113 | utils.Logger.Error("could not create docker client", zap.Error(err)) 114 | } 115 | ctx := context.TODO() 116 | _, err = dockerClient.CreateNetwork(ctx) 117 | if err != nil { 118 | if errors.Is(err, dockerservice.ErrDuplicateNetwork) { 119 | utils.Logger.Fatal(fmt.Sprintf("found multiple docker networks with name: '%s', remove them and restart Spinup.", dockerClient.NetworkName)) 120 | } else { 121 | utils.Logger.Fatal("unable to create docker network", zap.Error(err)) 122 | } 123 | } 124 | 125 | if appConfig.Common.Monitoring { 126 | monitorRuntime = monitor.NewRuntime(dockerClient, monitor.WithLogger(utils.Logger), monitor.WithAppConfig(appConfig)) 127 | if err := monitorRuntime.BootstrapServices(ctx); err != nil { 128 | utils.Logger.Error("could not start monitoring services", zap.Error(err)) 129 | } else { 130 | utils.Logger.Info("started spinup monitoring services") 131 | } 132 | } 133 | 134 | apiListener, err := net.Listen("tcp", apiPort) 135 | if err != nil { 136 | utils.Logger.Fatal("failed to start listener", zap.Error(err)) 137 | } 138 | apiServer := &http.Server{ 139 | Handler: apiHandler(), 140 | } 141 | defer stop(apiServer) 142 | 143 | stopCh := make(chan os.Signal, 1) 144 | go func() { 145 | utils.Logger.Info("starting Spinup API ", zap.String("port", apiPort)) 146 | if err = apiServer.Serve(apiListener); err != nil { 147 | utils.Logger.Fatal("failed to start API server", zap.Error(err)) 148 | } 149 | }() 150 | 151 | if apiOnly == false { 152 | uiListener, err := net.Listen("tcp", uiPort) 153 | if err != nil { 154 | utils.Logger.Fatal("failed to start UI server", zap.Error(err)) 155 | return 156 | } 157 | 158 | r := chi.NewRouter() 159 | r.Use(middleware.Logger) 160 | 161 | fs := http.FileServer(http.Dir(uiPath)) 162 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 163 | if r.URL.Path != "/" { 164 | fullPath := filepath.Join(uiPath, strings.TrimPrefix(path.Clean(r.URL.Path), "/")) 165 | _, err := os.Stat(fullPath) 166 | if err != nil { 167 | if !os.IsNotExist(err) { 168 | utils.Logger.Error("could not find asset", zap.Error(err)) 169 | } 170 | // Requested file does not exist, we return the default (resolves to index.html) 171 | r.URL.Path = "/" 172 | } 173 | } 174 | fs.ServeHTTP(w, r) 175 | }) 176 | 177 | uiServer := &http.Server{ 178 | Handler: http.DefaultServeMux, 179 | } 180 | go func() { 181 | utils.Logger.Info("starting Spinup UI", zap.String("port", uiPort)) 182 | if err = uiServer.Serve(uiListener); err != nil { 183 | utils.Logger.Fatal("failed to start UI server", zap.Error(err)) 184 | } 185 | }() 186 | defer stop(uiServer) 187 | } 188 | 189 | signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM) 190 | log.Println(fmt.Sprint(<-stopCh)) 191 | utils.Logger.Info(fmt.Sprint(<-stopCh)) 192 | utils.Logger.Info("stopping spinup apiServer") 193 | 194 | }, 195 | } 196 | 197 | home, err := os.UserHomeDir() 198 | if err != nil { 199 | utils.Logger.Fatal("obtaining home directory: ", zap.Error(err)) 200 | } 201 | sc.Flags().StringVar(&cfgFile, "config", 202 | fmt.Sprintf("%s/.local/spinup/config.yaml", home), "Path to spinup configuration") 203 | sc.Flags().StringVar(&uiPath, "ui-path", 204 | fmt.Sprintf("%s/.local/spinup/spinup-dash", home), "Path to spinup frontend") 205 | sc.Flags().BoolVar(&apiOnly, "api-only", false, "Only run the API server (without the UI server). Useful for development") 206 | 207 | return sc 208 | } 209 | 210 | func validateConfig(path string) error { 211 | file, err := os.Open(path) 212 | if err != nil { 213 | return err 214 | } 215 | defer file.Close() 216 | 217 | d := yaml.NewDecoder(file) 218 | if err = d.Decode(&appConfig); err != nil { 219 | return err 220 | } 221 | 222 | if appConfig.PromConfig.Port == 0 { 223 | appConfig.PromConfig.Port = 9090 224 | } 225 | 226 | signBytes, err := os.ReadFile(appConfig.Common.ProjectDir + "/app.rsa") 227 | if err != nil { 228 | return err 229 | } 230 | 231 | if appConfig.SignKey, err = jwt.ParseRSAPrivateKeyFromPEM(signBytes); err != nil { 232 | return err 233 | } 234 | 235 | verifyBytes, err := os.ReadFile(appConfig.Common.ProjectDir + "/app.rsa.pub") 236 | if err != nil { 237 | return err 238 | } 239 | 240 | if appConfig.VerifyKey, err = jwt.ParseRSAPublicKeyFromPEM(verifyBytes); err != nil { 241 | return err 242 | } 243 | 244 | return nil 245 | } 246 | 247 | func stop(server *http.Server) { 248 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) //nolint 249 | defer cancel() 250 | if err := server.Shutdown(ctx); err != nil { 251 | utils.Logger.Info("Can't stop Spinup API correctly:", zap.Error(err)) 252 | } 253 | } 254 | 255 | // isDockerdRunning returns true if docker daemon process is running on the host 256 | // ref: https://docs.docker.com/config/daemon/#check-whether-docker-is-running 257 | func isDockerdRunning(ctx context.Context) bool { 258 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 259 | defer cancel() 260 | stdout, err := exec.CommandContext(ctx, "docker", "info").CombinedOutput() 261 | if err != nil { 262 | return false 263 | } 264 | if strings.Contains(string(stdout), "ERROR") { 265 | return false 266 | } 267 | return true 268 | } 269 | -------------------------------------------------------------------------------- /internal/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/spinup-host/spinup/build" 9 | ) 10 | 11 | func versionCmd(bi build.Info) *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "version", 14 | Short: "Print the SpinUp version", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | fmt.Println(fmt.Sprintf( 17 | "Spinup version: %s.\nBuilt from: %s.\nCommit hash: %s", 18 | build.Version, 19 | build.Branch, 20 | build.FullCommit), 21 | ) 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/dockerservice/container.go: -------------------------------------------------------------------------------- 1 | package dockerservice 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "strings" 10 | 11 | "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/container" 13 | "github.com/docker/docker/api/types/filters" 14 | "github.com/docker/docker/api/types/network" 15 | "github.com/docker/docker/pkg/stdcopy" 16 | "github.com/pkg/errors" 17 | ) 18 | 19 | const ( 20 | PgExporterPrefix = "spinup-postgres-exporter" 21 | PrometheusPrefix = "spinup-prometheus" 22 | GrafanaPrefix = "spinup-grafana" 23 | ) 24 | 25 | var ErrNoMatchingEnv = fmt.Errorf("no matching environment variable") 26 | 27 | // Container represents a docker container 28 | type Container struct { 29 | ID string 30 | Name string 31 | Options types.ContainerStartOptions 32 | State string // current state of the docker container. Could be exited | running 33 | Config container.Config // portable docker config 34 | HostConfig container.HostConfig // non-portable docker config 35 | NetworkConfig network.NetworkingConfig 36 | Warning []string 37 | } 38 | 39 | // NewContainer returns a container with provided name. 40 | // todo: rest of the fields default value does make sense. We should look to remove those since they aren't adding any value 41 | func NewContainer(name string, config container.Config, hostConfig container.HostConfig, networkConfig network.NetworkingConfig) Container { 42 | return Container{ 43 | Name: name, 44 | Config: config, 45 | HostConfig: hostConfig, 46 | NetworkConfig: networkConfig, 47 | } 48 | } 49 | 50 | // Start creates and starts a docker container. If the base image doesn't exist locally, we attempt to pull it from 51 | // the docker registry. 52 | func (c *Container) Start(ctx context.Context, d Docker) (container.ContainerCreateCreatedBody, error) { 53 | body := container.ContainerCreateCreatedBody{} 54 | 55 | exists, err := imageExistsLocally(context.Background(), d, c.Config.Image) 56 | if err != nil { 57 | return body, errors.Wrap(err, "error checking whether the image exists locally") 58 | } 59 | if !exists { 60 | log.Printf("INFO: docker image %s doesn't exist on the host. Will attempt to pull in the background \n", c.Config.Image) 61 | if err := pullImageFromDockerRegistry(d, c.Config.Image); err != nil { 62 | return body, errors.Wrap(err, "pulling image from docker registry") 63 | } 64 | } 65 | 66 | body, err = d.Cli.ContainerCreate(ctx, &c.Config, &c.HostConfig, &c.NetworkConfig, nil, c.Name) 67 | switch { 68 | case err == nil: 69 | default: 70 | break 71 | case strings.Contains(err.Error(), "You have to remove (or rename) that container"): 72 | return body, ErrDuplicateContainerName 73 | case err != nil: 74 | return body, errors.Wrapf(err, "unable to create container with image %s", c.Config.Image) 75 | } 76 | 77 | err = d.Cli.ContainerStart(ctx, body.ID, c.Options) 78 | if err != nil { 79 | return body, errors.Wrapf(err, "unable to start container for image %s", c.Config.Image) 80 | } 81 | 82 | data, err := d.Cli.ContainerInspect(ctx, body.ID) 83 | if err != nil { 84 | return body, errors.Wrapf(err, "getting data for container %s", c.ID) 85 | } 86 | 87 | c.ID = body.ID 88 | c.Config = *data.Config 89 | c.NetworkConfig = network.NetworkingConfig{ 90 | EndpointsConfig: data.NetworkSettings.Networks, 91 | } 92 | 93 | log.Printf("started %s container with ID: %s", c.Name, c.ID) 94 | return body, nil 95 | } 96 | 97 | // StartExisting starts a docker container. Unlike Start(), this method only concerns itself with starting a container 98 | // and will not try to create it or pull the image. 99 | func (c *Container) StartExisting(ctx context.Context, d Docker) error { 100 | err := d.Cli.ContainerStart(ctx, c.ID, c.Options) 101 | if err != nil { 102 | return errors.Wrapf(err, "unable to start container %s", c.ID) 103 | } 104 | data, err := d.Cli.ContainerInspect(ctx, c.ID) 105 | if err != nil { 106 | return errors.Wrapf(err, "getting data for container %s", c.ID) 107 | } 108 | 109 | c.Config = *data.Config 110 | c.NetworkConfig = network.NetworkingConfig{ 111 | EndpointsConfig: data.NetworkSettings.Networks, 112 | } 113 | return nil 114 | } 115 | 116 | // Restart restarts a docker container. 117 | func (c *Container) Restart(ctx context.Context, d Docker) error { 118 | err := d.Cli.ContainerRestart(ctx, c.ID, container.StopOptions{Timeout: nil}) 119 | if err != nil { 120 | return errors.Wrapf(err, "unable to restart container: %s", c.ID) 121 | } 122 | data, err := d.Cli.ContainerInspect(ctx, c.ID) 123 | if err != nil { 124 | return errors.Wrapf(err, "getting data for container %s", c.ID) 125 | } 126 | 127 | c.Config = *data.Config 128 | c.NetworkConfig = network.NetworkingConfig{ 129 | EndpointsConfig: data.NetworkSettings.Networks, 130 | } 131 | 132 | return nil 133 | } 134 | 135 | // imageExistsLocally returns a boolean indicating if an image with the 136 | // requested name exists in the local docker image store 137 | func imageExistsLocally(ctx context.Context, d Docker, imageName string) (bool, error) { 138 | 139 | listFilters := filters.NewArgs() 140 | listFilters.Add("reference", imageName) 141 | 142 | imageListOptions := types.ImageListOptions{ 143 | Filters: listFilters, 144 | } 145 | 146 | images, err := d.Cli.ImageList(ctx, imageListOptions) 147 | if err != nil { 148 | return false, err 149 | } 150 | 151 | if len(images) > 0 { 152 | 153 | for _, v := range images { 154 | _, _, err := d.Cli.ImageInspectWithRaw(ctx, v.ID) 155 | if err != nil { 156 | return false, err 157 | } 158 | return true, nil 159 | 160 | } 161 | return false, nil 162 | } 163 | 164 | return false, nil 165 | } 166 | 167 | func pullImageFromDockerRegistry(d Docker, image string) error { 168 | rc, err := d.Cli.ImagePull(context.Background(), image, types.ImagePullOptions{ 169 | // Platform: "linux/amd64", 170 | }) 171 | if err != nil { 172 | return fmt.Errorf("unable to pull docker image %s %w", image, err) 173 | } 174 | defer rc.Close() 175 | _, err = io.ReadAll(rc) 176 | if err != nil { 177 | return fmt.Errorf("unable to download docker image %s %w", image, err) 178 | } 179 | return nil 180 | } 181 | 182 | // ExecCommand executes a given bash command through execConfig and displays the output in stdout and stderr 183 | // This function doesn't return an error for the failure of the command itself 184 | func (c Container) ExecCommand(ctx context.Context, d Docker, execConfig types.ExecConfig) (types.IDResponse, error) { 185 | if c.ID == "" { 186 | return types.IDResponse{}, errors.New("container id is empty") 187 | } 188 | if _, err := d.Cli.ContainerInspect(ctx, c.ID); err != nil { 189 | return types.IDResponse{}, errors.New("container doesn't exist") 190 | } 191 | execResponse, err := d.Cli.ContainerExecCreate(ctx, c.ID, execConfig) 192 | if err != nil { 193 | return types.IDResponse{}, fmt.Errorf("creating container exec %w", err) 194 | } 195 | resp, err := d.Cli.ContainerExecAttach(ctx, execResponse.ID, types.ExecStartCheck{Tty: false}) 196 | if err != nil { 197 | return types.IDResponse{}, fmt.Errorf("creating container exec attach %w", err) 198 | } 199 | defer resp.Close() 200 | // show the output to stdout and stderr 201 | if _, err := stdcopy.StdCopy(os.Stdout, os.Stderr, resp.Reader); err != nil { 202 | return types.IDResponse{}, fmt.Errorf("unable to copy the output of container, %w", err) 203 | } 204 | if err = d.Cli.ContainerExecStart(ctx, execResponse.ID, types.ExecStartCheck{}); err != nil { 205 | return types.IDResponse{}, fmt.Errorf("starting container exec %v", err) 206 | } 207 | return execResponse, nil 208 | } 209 | 210 | // Stop stops a running docker container. 211 | func (c *Container) Stop(ctx context.Context, d Docker, opts types.ContainerStartOptions) error { 212 | timeout := 20 // in seconds 213 | log.Println("stopping container: ", c.ID) 214 | return d.Cli.ContainerStop(ctx, c.ID, container.StopOptions{Timeout: &timeout}) 215 | } 216 | 217 | // Remove removes a stopped docker container 218 | func (c *Container) Remove(ctx context.Context, d Docker) error { 219 | log.Println("removing container: ", c.ID) 220 | return d.Cli.ContainerRemove(ctx, c.ID, types.ContainerRemoveOptions{}) 221 | } 222 | 223 | // GetEnv returns the value of an environment value in the container or an error if no match is found. 224 | func (c *Container) GetEnv(ctx context.Context, d Docker, key string) (string, error) { 225 | var value string 226 | var found bool 227 | for _, e := range c.Config.Env { 228 | if strings.Contains(e, key) { 229 | _, value, found = strings.Cut(e, "=") 230 | } 231 | } 232 | 233 | if value == "" && !found { 234 | return "", ErrNoMatchingEnv 235 | } 236 | return value, nil 237 | } 238 | -------------------------------------------------------------------------------- /internal/dockerservice/container_test.go: -------------------------------------------------------------------------------- 1 | package dockerservice 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/docker/docker/api/types/container" 8 | "github.com/docker/docker/api/types/network" 9 | "github.com/google/uuid" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestStart(t *testing.T) { 15 | testID := uuid.New().String() 16 | ctx := context.Background() 17 | dc, err := newDockerTest(ctx, testID) 18 | require.NoError(t, err) 19 | 20 | t.Cleanup(func() { 21 | err = dc.cleanup(t) 22 | if err != nil { 23 | t.Logf("failed to clean up test containers" + err.Error()) 24 | } 25 | }) 26 | 27 | t.Run("duplicate container name", func(t *testing.T) { 28 | c1 := NewContainer( 29 | "test_container", 30 | container.Config{Image: testImage}, 31 | container.HostConfig{}, 32 | network.NetworkingConfig{ 33 | EndpointsConfig: map[string]*network.EndpointSettings{ 34 | dc.NetworkName: {}, 35 | }, 36 | }, 37 | ) 38 | _, err = c1.Start(ctx, dc.Docker) 39 | assert.NoError(t, err) 40 | 41 | c2 := NewContainer( 42 | "test_container", 43 | container.Config{Image: testImage}, 44 | container.HostConfig{}, network.NetworkingConfig{ 45 | EndpointsConfig: map[string]*network.EndpointSettings{ 46 | dc.NetworkName: {}, 47 | }, 48 | }) 49 | _, err = c2.Start(ctx, dc.Docker) 50 | assert.ErrorIs(t, err, ErrDuplicateContainerName) 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /internal/dockerservice/dockerservice.go: -------------------------------------------------------------------------------- 1 | package dockerservice 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/docker/docker/api/types" 10 | "github.com/docker/docker/api/types/filters" 11 | "github.com/docker/docker/api/types/network" 12 | "github.com/docker/docker/api/types/volume" 13 | "github.com/docker/docker/client" 14 | "github.com/pkg/errors" 15 | 16 | "github.com/spinup-host/spinup/misc" 17 | ) 18 | 19 | type Docker struct { 20 | Cli *client.Client 21 | NetworkName string 22 | } 23 | 24 | // NewDocker returns a Docker struct 25 | func NewDocker(networkName string, opts ...client.Opt) (Docker, error) { 26 | cli, err := client.NewClientWithOpts(opts...) 27 | if err != nil { 28 | fmt.Printf("error creating client %v", err) 29 | } 30 | return Docker{NetworkName: networkName, Cli: cli}, nil 31 | } 32 | 33 | var ErrDuplicateNetwork = errors.New("duplicate networks found with given name") 34 | var ErrDuplicateContainerName = errors.New("a container already exists with the given name") 35 | 36 | // GetContainer returns a docker container with the provided name (if any exists). 37 | // if no match exists, it returns a nil container and a nil error. 38 | func (d Docker) GetContainer(ctx context.Context, name string) (*Container, error) { 39 | listFilters := filters.NewArgs() 40 | listFilters.Add("name", name) 41 | containers, err := d.Cli.ContainerList(ctx, types.ContainerListOptions{All: true, Filters: listFilters}) 42 | if err != nil { 43 | return &Container{}, fmt.Errorf("error listing containers %w", err) 44 | } 45 | 46 | for _, match := range containers { 47 | // TODO: name of the container has prefixed with "/" 48 | // I have hardcoded here; perhaps there is a better way to handle this 49 | if misc.SliceContainsString(match.Names, "/"+name) { 50 | data, err := d.Cli.ContainerInspect(ctx, match.ID) 51 | if err != nil { 52 | return nil, errors.Wrapf(err, "getting data for container %s", match.ID) 53 | } 54 | c := &Container{ 55 | ID: match.ID, 56 | Name: name, 57 | State: match.State, 58 | Config: *data.Config, 59 | // note that if the container is stopped, network info will be empty and won't be populated 60 | // until you call one of Start(), Restart(), or StartExisting(). 61 | NetworkConfig: network.NetworkingConfig{ 62 | EndpointsConfig: data.NetworkSettings.Networks, 63 | }, 64 | } 65 | return c, nil 66 | } 67 | } 68 | return nil, nil 69 | } 70 | 71 | // CreateNetwork creates a new Docker network. 72 | func (d Docker) CreateNetwork(ctx context.Context) (types.NetworkCreateResponse, error) { 73 | networkResponse, err := d.Cli.NetworkCreate(ctx, d.NetworkName, types.NetworkCreate{CheckDuplicate: true}) 74 | if err == nil { 75 | return networkResponse, nil 76 | } 77 | 78 | if !strings.Contains(err.Error(), fmt.Sprintf("network with name %s already exists", d.NetworkName)) { 79 | return networkResponse, err 80 | } else { 81 | listFilters := filters.NewArgs() 82 | listFilters.Add("name", d.NetworkName) 83 | networks, err := d.Cli.NetworkList(ctx, types.NetworkListOptions{Filters: listFilters}) 84 | if err != nil { 85 | return networkResponse, err 86 | } 87 | 88 | if len(networks) > 1 { 89 | // multiple networks with the same name exists. 90 | // we return an error and let the user clean them out 91 | return networkResponse, ErrDuplicateNetwork 92 | } 93 | return types.NetworkCreateResponse{ 94 | ID: networks[0].ID, 95 | }, nil 96 | } 97 | } 98 | 99 | // RemoveNetwork removes an existing docker network. 100 | func (d Docker) RemoveNetwork(ctx context.Context, networkID string) error { 101 | return d.Cli.NetworkRemove(ctx, networkID) 102 | } 103 | 104 | func CreateVolume(ctx context.Context, d Docker, opt volume.VolumeCreateBody) (types.Volume, error) { 105 | log.Println("INFO: volume created successfully ", opt.Name) 106 | return d.Cli.VolumeCreate(ctx, opt) 107 | } 108 | 109 | func RemoveVolume(ctx context.Context, d Docker, volumeID string) error { 110 | return d.Cli.VolumeRemove(ctx, volumeID, true) 111 | } 112 | -------------------------------------------------------------------------------- /internal/dockerservice/dockerservice_test.go: -------------------------------------------------------------------------------- 1 | package dockerservice 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/docker/docker/api/types" 10 | "github.com/docker/docker/api/types/container" 11 | "github.com/docker/docker/api/types/filters" 12 | "github.com/google/uuid" 13 | "github.com/pkg/errors" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | "go.uber.org/multierr" 17 | ) 18 | 19 | var testImage = "tianon/true" 20 | 21 | // dockerTest is a package-specific wrapper around Docker to be used for tests without creating an import cycle 22 | type dockerTest struct { 23 | Docker 24 | } 25 | 26 | func newDockerTest(ctx context.Context, networkName string) (dockerTest, error) { 27 | dc, err := NewDocker(networkName) 28 | if err != nil { 29 | return dockerTest{}, err 30 | } 31 | 32 | _, err = dc.CreateNetwork(ctx) 33 | if err != nil { 34 | return dockerTest{}, errors.Wrap(err, "create network") 35 | } 36 | return dockerTest{ 37 | Docker: dc, 38 | }, nil 39 | } 40 | 41 | // cleanup removes all containers and volumes in the docker network, and removes the network itself. 42 | func (dt dockerTest) cleanup(t *testing.T) error { 43 | t.Helper() 44 | ctx := context.Background() 45 | filter := filters.NewArgs() 46 | filter.Add("network", dt.NetworkName) 47 | 48 | containers, err := dt.Cli.ContainerList(ctx, types.ContainerListOptions{All: true, Filters: filter}) 49 | if err != nil { 50 | return errors.Wrap(err, "list containers") 51 | } 52 | 53 | var cleanupErr error 54 | stopTimeout := 1 // timeout in seconds 55 | for _, c := range containers { 56 | t.Log(c.Names) 57 | stopOpts := container.StopOptions{ 58 | Timeout: &stopTimeout, 59 | } 60 | if err = dt.Cli.ContainerStop(ctx, c.ID, stopOpts); err != nil { 61 | if strings.Contains(err.Error(), "No such container") { 62 | continue 63 | } 64 | cleanupErr = multierr.Append(cleanupErr, errors.Wrap(err, "stop container")) 65 | } 66 | if err = dt.Cli.ContainerRemove(ctx, c.ID, types.ContainerRemoveOptions{}); err != nil { 67 | if strings.Contains(err.Error(), "No such container") { 68 | continue 69 | } 70 | cleanupErr = multierr.Append(cleanupErr, errors.Wrap(err, "remove container")) 71 | } 72 | 73 | // cleanup its volumes 74 | for _, mount := range c.Mounts { 75 | if mount.Type == "volume" { 76 | if err = dt.Cli.VolumeRemove(ctx, mount.Name, true); err != nil { 77 | cleanupErr = multierr.Append(cleanupErr, errors.Wrap(err, "remove volume")) 78 | } 79 | } 80 | } 81 | } 82 | 83 | if err = dt.Cli.NetworkRemove(ctx, dt.NetworkName); err != nil { 84 | cleanupErr = multierr.Append(cleanupErr, errors.Wrap(err, "remove network")) 85 | } 86 | return nil 87 | } 88 | 89 | func Test_imageExistsLocally(t *testing.T) { 90 | ctx := context.Background() 91 | testID := uuid.New().String() 92 | dc, err := newDockerTest(ctx, testID) 93 | require.NoError(t, err) 94 | 95 | t.Cleanup(func() { 96 | err = dc.cleanup(t) 97 | if err != nil { 98 | t.Log("failed to clean test containers", err.Error()) 99 | } 100 | }) 101 | 102 | data := []struct { 103 | name string 104 | image string 105 | pullImageFromDockerRegistry bool 106 | expected bool 107 | }{ 108 | {"image exist", "tianon/true", true, true}, 109 | {"image does not exist", "imageDoesNotExist:notag", false, false}, 110 | } 111 | for _, d := range data { 112 | t.Run(d.name, func(t *testing.T) { 113 | if d.pullImageFromDockerRegistry { 114 | log.Println("INFO: pulling docker image from docker registry:", d.image) 115 | // INFO: not sure what's the best way to make sure an image exists locally, hence pulling it before testing imageExistsLocally. 116 | // Perhaps we could move this to TestMain() which means we need to define a type for struct - not sure if it's that the right way to do 117 | // postgres:9.6-alpine image will be pulled since its fairly small. It could be any image. 118 | if err := pullImageFromDockerRegistry(dc.Docker, d.image); err != nil { 119 | t.Errorf("error setting up imageExistsLocally() for test data %+v", d) 120 | } 121 | } 122 | actual, err := imageExistsLocally(context.Background(), dc.Docker, d.image) 123 | if err != nil { 124 | t.Errorf("error testing imageExistsLocally() for test data %+v", d) 125 | 126 | } 127 | if actual != d.expected { 128 | t.Errorf("incorrect result: actual %t , expected %t", actual, d.expected) 129 | } 130 | }) 131 | } 132 | } 133 | 134 | func Test_pullImageFromDockerRegistry(t *testing.T) { 135 | ctx := context.Background() 136 | testID := uuid.New().String() 137 | dc, err := newDockerTest(ctx, testID) 138 | require.NoError(t, err) 139 | 140 | t.Cleanup(func() { 141 | assert.NoError(t, dc.cleanup(t)) 142 | }) 143 | 144 | data := []struct { 145 | name string 146 | image string 147 | expected error 148 | }{ 149 | {"image exist", "tianon/true", nil}, 150 | {"image does not exist", "imageDoesNotExistInRegistry:notag", errors.New("unable to pull docker image")}, 151 | } 152 | for _, d := range data { 153 | t.Run(d.name, func(t *testing.T) { 154 | actual := pullImageFromDockerRegistry(dc.Docker, d.image) 155 | if actual != d.expected { 156 | if !strings.Contains(actual.Error(), d.expected.Error()) { 157 | t.Errorf("incorrect result: actual %t , expected %t", actual, d.expected) 158 | } 159 | } 160 | }) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /internal/metastore/metastore.go: -------------------------------------------------------------------------------- 1 | package metastore 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "log" 9 | 10 | _ "modernc.org/sqlite" 11 | ) 12 | 13 | type Db struct { 14 | Client *sql.DB 15 | } 16 | 17 | type ClusterInfo struct { 18 | // one of arm64v8 or arm32v7 or amd64 19 | Architecture string `json:"architecture,omitempty"` 20 | 21 | Type string `json:"type"` // only "postgres" is supported at the moment 22 | Host string `json:"host"` 23 | ID int `json:"id,omitempty"` 24 | ClusterID string `json:"cluster_id"` 25 | Name string `json:"name"` 26 | Port int `json:"port"` 27 | Username string `json:"username"` 28 | Password string `json:"password,omitempty"` 29 | MajVersion int `json:"majversion"` 30 | MinVersion int `json:"minversion"` 31 | Monitoring string `json:"monitoring,omitempty"` 32 | CPU int64 `json:"cpu,omitempty"` 33 | Memory int64 `json:"memory,omitempty"` 34 | 35 | BackupEnabled bool `json:"backup_enabled,omitempty"` 36 | Backup BackupConfig `json:"backup,omitempty"` 37 | } 38 | 39 | type BackupConfig struct { 40 | // https://man7.org/linux/man-pages/man5/crontab.5.html 41 | Schedule map[string]interface{} 42 | Dest Destination `json:"Dest"` 43 | } 44 | 45 | type Destination struct { 46 | Name string 47 | BucketName string 48 | ApiKeyID string 49 | ApiKeySecret string 50 | } 51 | 52 | // clustersInfo type has methods which provide us to filter them by name etc. 53 | type clustersInfo []ClusterInfo 54 | 55 | // FilterByName filters cluster by name. Since cluster names are unique as soon as we find a match we return 56 | func (c clustersInfo) FilterByName(name string) (ClusterInfo, error) { 57 | for _, clusterInfo := range c { 58 | if clusterInfo.Name == name { 59 | return clusterInfo, nil 60 | } 61 | } 62 | return ClusterInfo{}, errors.New("cluster not found") 63 | } 64 | 65 | func NewDb(path string) (Db, error) { 66 | db, err := sql.Open("sqlite", path) 67 | if err != nil { 68 | return Db{}, fmt.Errorf("unable to create a new db sqlite db client %w", err) 69 | } 70 | return Db{Client: db}, nil 71 | } 72 | 73 | // migration creates table 74 | func migration(ctx context.Context, db Db) error { 75 | sqlStatements := []string{ 76 | "create table if not exists clusterInfo (id integer not null primary key autoincrement, clusterId text, name text, username text, password text, port integer, majVersion integer, minVersion integer);", 77 | "create table if not exists backup (id integer not null primary key autoincrement, clusterid text, destination text, bucket text, second integer, minute integer, hour integer, dom integer, month integer, dow integer, foreign key(clusterid) references clusterinfo(clusterid));", 78 | } 79 | tx, err := db.Client.Begin() 80 | if err != nil { 81 | return fmt.Errorf("couldn't begin a transaction %w", err) 82 | 83 | } 84 | for _, sqlStatement := range sqlStatements { 85 | _, err = tx.ExecContext(ctx, sqlStatement) 86 | if err != nil { 87 | return fmt.Errorf("couldn't execute a transaction for %s %w", sqlStatement, err) 88 | } 89 | } 90 | err = tx.Commit() 91 | if err != nil { 92 | return fmt.Errorf("couldn't commit a transaction %w", err) 93 | } 94 | return nil 95 | } 96 | 97 | // InsertService adds a new row containing the cluster/service info to the database. 98 | // TODO: How to write generic functions with varying fields and types? Maybe generics 99 | func InsertService(db Db, cluster ClusterInfo) error { 100 | query := "insert into clusterInfo(clusterId, name, username, password, port, majVersion, minVersion) values(?, ?, ?, ?, ?, ?, ?)" 101 | tx, err := db.Client.Begin() 102 | if err != nil { 103 | return fmt.Errorf("unable to begin a transaction %w", err) 104 | } 105 | if err = migration(context.Background(), db); err != nil { 106 | return fmt.Errorf("error running a migration %w", err) 107 | } 108 | _, err = tx.ExecContext(context.Background(), query, cluster.ClusterID, cluster.Name, cluster.Username, cluster.Password, cluster.Port, cluster.MajVersion, cluster.MinVersion) 109 | if err != nil { 110 | if rollbackErr := tx.Rollback(); rollbackErr != nil { 111 | log.Println("ERROR: failed to rollback transaction: ", rollbackErr) 112 | } 113 | return fmt.Errorf("unable to execute %s %v", query, err) 114 | } 115 | 116 | err = tx.Commit() 117 | if err != nil { 118 | return err 119 | } 120 | return nil 121 | } 122 | 123 | func InsertBackup(db Db, sql, clusterId, destination, bucket string, second, minute, hour, dom, month, dow int) error { 124 | tx, err := db.Client.Begin() 125 | if err != nil { 126 | return fmt.Errorf("unable to begin a transaction %w", err) 127 | } 128 | res, err := tx.ExecContext(context.Background(), sql, clusterId, destination, bucket, second, minute, hour, dom, month, dow) 129 | if err != nil { 130 | return fmt.Errorf("unable to execute %s %v", sql, err) 131 | } 132 | rows, _ := res.RowsAffected() 133 | log.Println("INFO: rows inserted into backup table:", rows) 134 | err = tx.Commit() 135 | if err != nil { 136 | return err 137 | } 138 | return nil 139 | } 140 | 141 | // AllClusters returns all clusters from clusterinfo table 142 | func AllClusters(db Db) (clustersInfo, error) { 143 | if err := db.Client.Ping(); err != nil { 144 | return nil, fmt.Errorf("error pinging sqlite database %w", err) 145 | } 146 | if err := migration(context.Background(), db); err != nil { 147 | return nil, fmt.Errorf("error running a migration %w", err) 148 | } 149 | rows, err := db.Client.Query("select id, clusterId, name, username, password, port, majversion, minversion from clusterInfo") 150 | if err != nil { 151 | return nil, fmt.Errorf("unable to query clusterinfo") 152 | } 153 | defer rows.Close() 154 | var csi clustersInfo 155 | var cluster ClusterInfo 156 | for rows.Next() { 157 | err = rows.Scan(&cluster.ID, &cluster.ClusterID, &cluster.Name, &cluster.Username, &cluster.Password, &cluster.Port, &cluster.MajVersion, &cluster.MinVersion) 158 | if err != nil { 159 | log.Fatal(err) 160 | } 161 | cluster.Host = "localhost" 162 | csi = append(csi, cluster) 163 | } 164 | return csi, nil 165 | } 166 | 167 | // GetClusterByID returns info about the cluster whose ID is provided. 168 | func GetClusterByID(db Db, clusterId string) (ClusterInfo, error) { 169 | var ci ClusterInfo 170 | query := `SELECT id, clusterId, name, username, password, port, majVersion, minVersion FROM clusterInfo WHERE clusterId = ? LIMIT 1` 171 | err := db.Client.QueryRow(query, clusterId).Scan( 172 | &ci.ID, 173 | &ci.ClusterID, 174 | &ci.Name, 175 | &ci.Username, 176 | &ci.Password, 177 | &ci.Port, 178 | &ci.MajVersion, 179 | &ci.MinVersion, 180 | ) 181 | ci.Host = "localhost" // filled since we don't save the host yet. 182 | if errors.Is(err, sql.ErrNoRows) { 183 | return ci, errors.New(fmt.Sprintf("no cluster with ID: '%s' was found", clusterId)) 184 | } 185 | return ci, err 186 | } 187 | 188 | // GetClusterByName returns info about the cluster whose name is provided. 189 | func GetClusterByName(db Db, clusterName string) (ClusterInfo, error) { 190 | var ci ClusterInfo 191 | query := `SELECT id, clusterId, name, username, password, port, majVersion, minVersion FROM clusterInfo WHERE name = ? LIMIT 1` 192 | err := db.Client.QueryRow(query, clusterName).Scan( 193 | &ci.ID, 194 | &ci.ClusterID, 195 | &ci.Name, 196 | &ci.Username, 197 | &ci.Password, 198 | &ci.Port, 199 | &ci.MajVersion, 200 | &ci.MinVersion, 201 | ) 202 | ci.Host = "localhost" // filled since we don't save the host yet. 203 | return ci, err 204 | } 205 | -------------------------------------------------------------------------------- /internal/metastore/metastore_test.go: -------------------------------------------------------------------------------- 1 | package metastore 2 | 3 | import ( 4 | "context" 5 | "crypto/sha1" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestServices(t *testing.T) { 16 | t.Parallel() 17 | tmpDir, err := os.MkdirTemp("", "") 18 | require.NoError(t, err) 19 | 20 | path := filepath.Join(tmpDir, "test.db") 21 | defer func(name string) { 22 | _ = os.Remove(name) 23 | }(path) 24 | 25 | db, err := NewDb(path) 26 | require.NoError(t, err) 27 | 28 | clusters := []ClusterInfo{ 29 | { 30 | Host: "localhost", 31 | Name: "db1", 32 | ClusterID: generateID("db1"), 33 | Username: "user1", 34 | Password: "password1", 35 | Port: 9001, 36 | MajVersion: 13, 37 | MinVersion: 0, 38 | }, { 39 | Host: "localhost", 40 | Name: "db2", 41 | ClusterID: generateID("db2"), 42 | Username: "user2", 43 | Password: "password2", 44 | Port: 9001, 45 | MajVersion: 13, 46 | MinVersion: 0, 47 | }, { 48 | Host: "localhost", 49 | Name: "db3", 50 | ClusterID: generateID("db3"), 51 | Username: "user3", 52 | Password: "password3", 53 | Port: 9002, 54 | MajVersion: 13, 55 | MinVersion: 0, 56 | }, { 57 | Host: "localhost", 58 | Name: "db4", 59 | ClusterID: generateID("db4"), 60 | Username: "user4", 61 | Password: "password4", 62 | Port: 9003, 63 | MajVersion: 13, 64 | MinVersion: 0, 65 | }, 66 | } 67 | 68 | t.Run("migration fails for bad client", func(t *testing.T) { 69 | t.Parallel() 70 | badDB, err := NewDb(os.TempDir()) // attempt to use a directory as sqlite path should fail 71 | assert.NoError(t, err) 72 | require.Error(t, migration(context.TODO(), badDB)) 73 | }) 74 | 75 | t.Run("filter by name", func(t *testing.T) { 76 | ci := clustersInfo(clusters) 77 | match, err := ci.FilterByName("db1") 78 | assert.NoError(t, err) 79 | assert.Equal(t, clusters[0], match) 80 | 81 | noMatch, err := ci.FilterByName("random_db") 82 | assert.Error(t, err) 83 | assert.Equal(t, "", noMatch.Name) 84 | }) 85 | 86 | t.Run("insert service", func(t *testing.T) { 87 | for _, cluster := range clusters { 88 | assert.NoError(t, InsertService(db, cluster)) 89 | } 90 | }) 91 | 92 | t.Run("get all clusters", func(t *testing.T) { 93 | result, err := AllClusters(db) 94 | 95 | assert.NoError(t, err) 96 | assert.Equal(t, len(clusters), len(result)) 97 | }) 98 | 99 | t.Run("get cluster by ID", func(t *testing.T) { 100 | result, err := GetClusterByID(db, generateID("db1")) 101 | assert.NoError(t, err) 102 | 103 | // we don't compare the struct directly since it may have been modified (e.g., auto-incremented ID field is added) 104 | // and thus, won't be equal. 105 | assert.Equal(t, clusters[0].ClusterID, result.ClusterID) 106 | }) 107 | } 108 | 109 | func generateID(name string) string { 110 | sha := sha1.New() 111 | sha.Write([]byte(name)) 112 | return fmt.Sprintf("%x", sha.Sum(nil)) 113 | } 114 | -------------------------------------------------------------------------------- /internal/monitor/monitor.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "fmt" 7 | "net" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "text/template" 12 | 13 | "github.com/docker/docker/api/types" 14 | "github.com/docker/docker/api/types/container" 15 | "github.com/docker/docker/api/types/mount" 16 | "github.com/docker/docker/api/types/network" 17 | "github.com/docker/go-connections/nat" 18 | "github.com/pkg/errors" 19 | "go.uber.org/zap" 20 | 21 | "github.com/spinup-host/spinup/internal/dockerservice" 22 | "github.com/spinup-host/spinup/misc" 23 | ) 24 | 25 | const ( 26 | pgExporterImageTag = "quay.io/prometheuscommunity/postgres-exporter:v0.10.1" 27 | grafanaImageTag = "grafana/grafana-oss:9.0.5" 28 | prometheusImageTag = "bitnami/prometheus:2.38.0" 29 | ) 30 | 31 | var ( 32 | defaultDatasourceCfg string 33 | defaultDashboardCfg string 34 | //go:embed pg-exporter-dashboard.json 35 | defaultDashboardDef string 36 | ) 37 | 38 | // Target represents a postgres service for monitoring. 39 | // it contains only fields that differ between different services 40 | type Target struct { 41 | ContainerName string 42 | UserName string 43 | Password string 44 | Port int 45 | } 46 | 47 | // BootstrapServices starts up prometheus and exporter services in docker containers 48 | // todo: clean up started services on SIGKILL or SIGTERM 49 | func (r *Runtime) BootstrapServices(ctx context.Context) error { 50 | var err error 51 | var promContainer *dockerservice.Container 52 | var pgExporterContainer *dockerservice.Container 53 | var gfContainer *dockerservice.Container 54 | 55 | defer func() { 56 | if err != nil { 57 | if promContainer != nil { 58 | if stopErr := promContainer.Stop(ctx, r.dockerClient, types.ContainerStartOptions{}); err != nil { 59 | r.logger.Error("stopping prometheus container", zap.Error(stopErr)) 60 | } 61 | } 62 | 63 | if pgExporterContainer != nil { 64 | stopErr := pgExporterContainer.Stop(ctx, r.dockerClient, types.ContainerStartOptions{}) 65 | r.logger.Error("stopping exporter container", zap.Error(stopErr)) 66 | } 67 | 68 | if gfContainer != nil { 69 | if stopErr := gfContainer.Stop(ctx, r.dockerClient, types.ContainerStartOptions{}); err != nil { 70 | r.logger.Error("stopping grafana container", zap.Error(stopErr)) 71 | } 72 | } 73 | } 74 | }() 75 | 76 | { 77 | promContainer, err = r.dockerClient.GetContainer(ctx, r.prometheusName) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | if err == nil && promContainer == nil { 83 | promCfgPath, err := r.getPromConfigPath() 84 | if err != nil { 85 | return errors.Wrap(err, "failed to mount prometheus config") 86 | } 87 | promContainer, err = r.newPrometheusContainer(promCfgPath) 88 | if err != nil { 89 | return err 90 | } 91 | _, err = promContainer.Start(ctx, r.dockerClient) 92 | if err != nil { 93 | return errors.Wrap(err, "failed to start prometheus container") 94 | } 95 | 96 | // we expect all containers to have the same gateway IP, but we assign it here 97 | // so that we can update the prometheus config with the right IP of targets 98 | r.dockerHostAddr = promContainer.NetworkConfig.EndpointsConfig[r.dockerClient.NetworkName].Gateway 99 | if err = r.writePromConfig(promCfgPath); err != nil { 100 | return errors.Wrap(err, "failed to update prometheus config") 101 | } 102 | } else { 103 | r.logger.Info("reusing existing prometheus container") 104 | err = promContainer.StartExisting(ctx, r.dockerClient) 105 | if err != nil { 106 | return errors.Wrap(err, "failed to start existing prometheus container") 107 | } 108 | // if the container exists, we only update the host address without over-writing the existing prometheus config 109 | r.dockerHostAddr = promContainer.NetworkConfig.EndpointsConfig[r.dockerClient.NetworkName].Gateway 110 | } 111 | } 112 | { 113 | pgExporterContainer, err = r.dockerClient.GetContainer(ctx, r.pgExporterName) 114 | if err != nil { 115 | return err 116 | } 117 | if err == nil && pgExporterContainer == nil { 118 | pgExporterContainer, err = r.newPostgresExporterContainer("") 119 | if err != nil { 120 | return err 121 | } 122 | _, err = pgExporterContainer.Start(ctx, r.dockerClient) 123 | if err != nil { 124 | return errors.Wrap(err, "failed to start pg_exporter container") 125 | } 126 | } else { 127 | r.logger.Info("reusing existing postgres_exporter container") 128 | err = pgExporterContainer.StartExisting(ctx, r.dockerClient) 129 | if err != nil { 130 | return errors.Wrap(err, "failed to start existing pg_exporter container") 131 | } 132 | } 133 | r.dockerHostAddr = pgExporterContainer.NetworkConfig.EndpointsConfig[r.dockerClient.NetworkName].Gateway 134 | } 135 | { 136 | gfContainer, err = r.dockerClient.GetContainer(ctx, r.grafanaContainerName) 137 | if err != nil { 138 | return err 139 | } 140 | sourceDir, dashboardDir, err := r.grafanaConfigDir() 141 | if err != nil { 142 | return errors.Wrap(err, "failed to read grafana config") 143 | } 144 | 145 | if err == nil && gfContainer == nil { 146 | gfContainer, err = r.newGrafanaContainer(sourceDir, dashboardDir) 147 | if err != nil { 148 | return err 149 | } 150 | _, err = gfContainer.Start(ctx, r.dockerClient) 151 | if err != nil { 152 | return errors.Wrap(err, "start grafana container") 153 | } 154 | if err = r.writeGrafanaConfig(sourceDir, dashboardDir); err != nil { 155 | return errors.Wrap(err, "set up grafana config") 156 | } 157 | if err = gfContainer.Restart(ctx, r.dockerClient); err != nil { 158 | return err 159 | } 160 | } else { 161 | r.logger.Info("reusing existing grafana container") 162 | err = gfContainer.StartExisting(ctx, r.dockerClient) 163 | if err != nil { 164 | return errors.Wrap(err, "start existing grafana container") 165 | } 166 | } 167 | r.dockerHostAddr = gfContainer.NetworkConfig.EndpointsConfig[r.dockerClient.NetworkName].Gateway 168 | } 169 | 170 | r.logger.Info(fmt.Sprintf("using docker host address :%s", r.dockerHostAddr)) 171 | r.pgExporterContainer = pgExporterContainer 172 | r.prometheusContainer = promContainer 173 | return nil 174 | } 175 | 176 | func (r *Runtime) newPostgresExporterContainer(dsn string) (*dockerservice.Container, error) { 177 | endpointConfig := map[string]*network.EndpointSettings{} 178 | endpointConfig[r.dockerClient.NetworkName] = &network.EndpointSettings{} 179 | nwConfig := network.NetworkingConfig{EndpointsConfig: endpointConfig} 180 | env := []string{ 181 | misc.StringToDockerEnvVal("DATA_SOURCE_NAME", dsn), 182 | } 183 | 184 | metricsPort := nat.Port("9187/tcp") 185 | postgresExporterContainer := dockerservice.NewContainer( 186 | r.pgExporterName, 187 | container.Config{ 188 | Image: pgExporterImageTag, 189 | Env: env, 190 | }, 191 | container.HostConfig{ 192 | PortBindings: nat.PortMap{ 193 | metricsPort: []nat.PortBinding{{ 194 | HostIP: "0.0.0.0", 195 | HostPort: "9187", 196 | }}, 197 | }, 198 | }, 199 | nwConfig, 200 | ) 201 | return &postgresExporterContainer, nil 202 | } 203 | 204 | func (r *Runtime) newPrometheusContainer(promCfgPath string) (*dockerservice.Container, error) { 205 | endpointConfig := map[string]*network.EndpointSettings{} 206 | endpointConfig[r.dockerClient.NetworkName] = &network.EndpointSettings{} 207 | nwConfig := network.NetworkingConfig{EndpointsConfig: endpointConfig} 208 | 209 | promDataDir := filepath.Join(r.appConfig.Common.ProjectDir, "prom_data") 210 | err := os.Mkdir(promDataDir, 0644) 211 | if err != nil && !errors.Is(err, os.ErrExist) { 212 | return &dockerservice.Container{}, errors.Wrap(err, "could not create prometheus data dir") 213 | } 214 | 215 | // Mount points for prometheus config and prometheus persistence 216 | mounts := []mount.Mount{ 217 | { 218 | Type: mount.TypeBind, 219 | Source: promCfgPath, 220 | Target: "/opt/bitnami/prometheus/conf/prometheus.yml", 221 | }, 222 | { 223 | Type: mount.TypeBind, 224 | Source: promDataDir, 225 | Target: "/opt/bitnami/prometheus/data", 226 | }, 227 | } 228 | 229 | promContainer := dockerservice.NewContainer( 230 | r.prometheusName, 231 | container.Config{ 232 | Image: prometheusImageTag, 233 | User: "root", 234 | }, 235 | container.HostConfig{ 236 | PortBindings: nat.PortMap{ 237 | "9090/tcp": []nat.PortBinding{{ 238 | HostIP: "0.0.0.0", 239 | HostPort: strconv.Itoa(r.appConfig.PromConfig.Port), 240 | }}, 241 | }, 242 | Mounts: mounts, 243 | }, 244 | nwConfig, 245 | ) 246 | return &promContainer, nil 247 | } 248 | 249 | func (r *Runtime) newGrafanaContainer(datasourceDir, dashboardDir string) (*dockerservice.Container, error) { 250 | endpointConfig := map[string]*network.EndpointSettings{} 251 | endpointConfig[r.dockerClient.NetworkName] = &network.EndpointSettings{} 252 | nwConfig := network.NetworkingConfig{EndpointsConfig: endpointConfig} 253 | 254 | mounts := []mount.Mount{ 255 | { 256 | Type: mount.TypeBind, 257 | Source: filepath.Join(datasourceDir, "prometheus-grafana.yaml"), 258 | Target: "/etc/grafana/provisioning/datasources/prometheus-grafana.yaml", 259 | }, 260 | { 261 | Type: mount.TypeBind, 262 | Source: filepath.Join(dashboardDir, "spinup.yaml"), 263 | Target: "/etc/grafana/provisioning/dashboards/spinup.yaml", 264 | }, 265 | { 266 | Type: mount.TypeBind, 267 | Source: filepath.Join(dashboardDir, "pg-exporter-dashboard.json"), 268 | Target: "/etc/grafana/provisioning/dashboards/pg-exporter-dashboard.json", 269 | }, 270 | } 271 | 272 | gfContainer := dockerservice.NewContainer( 273 | r.grafanaContainerName, container.Config{ 274 | Image: grafanaImageTag, 275 | }, 276 | container.HostConfig{ 277 | PortBindings: nat.PortMap{ 278 | nat.Port("3000/tcp"): []nat.PortBinding{{ 279 | HostIP: "0.0.0.0", 280 | HostPort: "9091", 281 | }}, 282 | }, 283 | Mounts: mounts, 284 | }, 285 | nwConfig, 286 | ) 287 | return &gfContainer, nil 288 | } 289 | 290 | func (r *Runtime) getPromConfigPath() (string, error) { 291 | cfgPath := filepath.Join(r.appConfig.Common.ProjectDir, "prometheus.yml") 292 | _, err := os.Open(cfgPath) 293 | if errors.Is(err, os.ErrNotExist) { 294 | _, err = os.Create(cfgPath) 295 | } 296 | 297 | return cfgPath, err 298 | } 299 | 300 | func (r *Runtime) writePromConfig(cfgPath string) error { 301 | cfg := fmt.Sprintf(`scrape_configs: 302 | - job_name: prometheus 303 | scrape_interval: 5s 304 | static_configs: 305 | - targets: 306 | - "%s" 307 | - job_name: pg_exporter 308 | scrape_interval: 5s 309 | static_configs: 310 | - targets: 311 | - "%s" 312 | `, net.JoinHostPort(r.dockerHostAddr, strconv.Itoa(r.appConfig.PromConfig.Port)), net.JoinHostPort(r.dockerHostAddr, "9187")) 313 | if err := os.WriteFile(cfgPath, []byte(cfg), 0644); err != nil { 314 | return err 315 | } 316 | return nil 317 | } 318 | 319 | func (r *Runtime) grafanaConfigDir() (datasourceDir, dashboardDir string, err error) { 320 | datasourceDir = filepath.Join(r.appConfig.Common.ProjectDir, "grafana", "datasources") 321 | _, err = os.Stat(datasourceDir) 322 | if errors.Is(err, os.ErrNotExist) { 323 | err = os.MkdirAll(datasourceDir, os.ModePerm) 324 | } 325 | if err != nil { 326 | return "", "", err 327 | } 328 | 329 | dashboardDir = filepath.Join(r.appConfig.Common.ProjectDir, "grafana", "dashboards") 330 | _, err = os.Stat(dashboardDir) 331 | if errors.Is(err, os.ErrNotExist) { 332 | err = os.MkdirAll(dashboardDir, os.ModePerm) 333 | } 334 | if err != nil { 335 | return "", "", err 336 | } 337 | 338 | if _, err = os.Create(filepath.Join(datasourceDir, "prometheus-grafana.yaml")); err != nil { 339 | return "", "", err 340 | } 341 | if _, err = os.Create(filepath.Join(dashboardDir, "spinup.yaml")); err != nil { 342 | return "", "", err 343 | } 344 | if _, err = os.Create(filepath.Join(dashboardDir, "pg-exporter-dashboard.json")); err != nil { 345 | return "", "", err 346 | } 347 | 348 | return datasourceDir, dashboardDir, err 349 | } 350 | 351 | func (r *Runtime) writeGrafanaConfig(datasourceDir, dashboardDir string) error { 352 | defaultDatasourceCfg = fmt.Sprintf(`# config file for provisioning prometheus data source in grafana 353 | apiVersion: 1 354 | 355 | deleteDatasources: 356 | - name: Prometheus 357 | orgId: 1 358 | 359 | datasources: 360 | - name: DS_PROMETHEUS 361 | type: prometheus 362 | access: 'proxy' 363 | orgId: 1 364 | url: http://%s 365 | isDefault: true 366 | version: 1 367 | editable: true 368 | `, net.JoinHostPort(r.dockerHostAddr, strconv.Itoa(r.appConfig.PromConfig.Port))) 369 | 370 | defaultDashboardCfg = fmt.Sprintf(`# config file for provisioning postgres dashboard in grafana 371 | apiVersion: 1 372 | 373 | providers: 374 | - name: Postgres Exporter 375 | folder: '' 376 | allowUiUpdates: true 377 | type: file 378 | options: 379 | path: /etc/grafana/provisioning/dashboards 380 | `) 381 | 382 | datasourcePath := filepath.Join(datasourceDir, "prometheus-grafana.yaml") 383 | dashboardPath := filepath.Join(dashboardDir, "spinup.yaml") 384 | dashboardDefFile := filepath.Join(dashboardDir, "pg-exporter-dashboard.json") 385 | 386 | if err := os.WriteFile(datasourcePath, []byte(defaultDatasourceCfg), 0644); err != nil { 387 | return errors.Wrap(err, "create grafana datasource config") 388 | } 389 | if err := os.WriteFile(dashboardPath, []byte(defaultDashboardCfg), 0644); err != nil { 390 | return errors.Wrap(err, "create grafana dashboard config") 391 | } 392 | 393 | tpl, err := template.New("exporter-file").Delims("<<", ">>").Parse(defaultDashboardDef) 394 | if err != nil { 395 | return errors.Wrap(err, "parsing dashboard definition") 396 | } 397 | 398 | // todo: dynamic port for exporter 399 | exporterInstance := net.JoinHostPort(r.dockerHostAddr, "9187") 400 | dashboardFile, err := os.Create(dashboardDefFile) 401 | if err != nil { 402 | return errors.Wrap(err, "creating dashboard file") 403 | } 404 | 405 | defer dashboardFile.Close() 406 | params := struct { 407 | DefaultPgExporterInstance string 408 | }{ 409 | DefaultPgExporterInstance: exporterInstance, 410 | } 411 | if err = tpl.Execute(dashboardFile, params); err != nil { 412 | return errors.Wrap(err, "create grafana dashboard definition") 413 | } 414 | return nil 415 | } 416 | -------------------------------------------------------------------------------- /internal/monitor/runtime.go: -------------------------------------------------------------------------------- 1 | // Package monitor is responsible for managing monitoring-related services such as 2 | // prometheus and prometheus exporters. 3 | package monitor 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "strconv" 9 | 10 | "github.com/docker/docker/api/types" 11 | "github.com/pkg/errors" 12 | "go.uber.org/zap" 13 | 14 | "github.com/spinup-host/spinup/config" 15 | ds "github.com/spinup-host/spinup/internal/dockerservice" 16 | ) 17 | 18 | const ( 19 | DsnKey = "DATA_SOURCE_NAME" 20 | ) 21 | 22 | // Runtime wraps runtime configuration and state of the monitoring service 23 | type Runtime struct { 24 | targets []*Target 25 | pgExporterName string 26 | prometheusName string 27 | grafanaContainerName string 28 | pgExporterContainer *ds.Container 29 | prometheusContainer *ds.Container 30 | dockerClient ds.Docker 31 | dockerHostAddr string 32 | 33 | appConfig config.Configuration 34 | logger *zap.Logger 35 | } 36 | 37 | type RuntimeOptions func(runtime *Runtime) 38 | 39 | func WithLogger(logger *zap.Logger) RuntimeOptions { 40 | return func(runtime *Runtime) { 41 | runtime.logger = logger 42 | } 43 | } 44 | 45 | func WithAppConfig(cfg config.Configuration) RuntimeOptions { 46 | return func(runtime *Runtime) { 47 | runtime.appConfig = cfg 48 | } 49 | } 50 | 51 | func NewRuntime(dockerClient ds.Docker, opts ...RuntimeOptions) *Runtime { 52 | rt := &Runtime{ 53 | targets: make([]*Target, 0), 54 | dockerClient: dockerClient, 55 | pgExporterName: ds.PgExporterPrefix, 56 | prometheusName: ds.PrometheusPrefix, 57 | grafanaContainerName: ds.GrafanaPrefix, 58 | } 59 | if dockerClient.NetworkName != "" { 60 | rt.pgExporterName = ds.PgExporterPrefix + "-" + dockerClient.NetworkName 61 | rt.prometheusName = ds.PrometheusPrefix + "-" + dockerClient.NetworkName 62 | rt.grafanaContainerName = ds.GrafanaPrefix + "-" + dockerClient.NetworkName 63 | } 64 | 65 | for _, opt := range opts { 66 | opt(rt) 67 | } 68 | return rt 69 | } 70 | 71 | // AddTarget adds a new service to the list of targets being monitored. 72 | func (r *Runtime) AddTarget(ctx context.Context, t *Target) error { 73 | oldDSN, err := r.pgExporterContainer.GetEnv(ctx, r.dockerClient, DsnKey) 74 | if err != nil { 75 | return errors.Wrap(err, "could not get current data sources from postgres_exporter") 76 | } 77 | 78 | if err := r.pgExporterContainer.Stop(ctx, r.dockerClient, types.ContainerStartOptions{}); err != nil { 79 | return err 80 | } 81 | if err := r.pgExporterContainer.Remove(ctx, r.dockerClient); err != nil { 82 | return err 83 | } 84 | 85 | var newDSN string 86 | if oldDSN == "" { 87 | newDSN = fmt.Sprintf("postgresql://%s:%s@%s:%s/?sslmode=disable", t.UserName, t.Password, r.dockerHostAddr, strconv.Itoa(t.Port)) 88 | } else { 89 | newDSN = fmt.Sprintf("%s,postgresql://%s:%s@%s:%s/?sslmode=disable", oldDSN, t.UserName, t.Password, r.dockerHostAddr, strconv.Itoa(t.Port)) 90 | } 91 | newContainer, err := r.newPostgresExporterContainer(newDSN) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | _, err = newContainer.Start(ctx, r.dockerClient) 97 | if err != nil { 98 | r.logger.Error("stopping exporter container", zap.Error(newContainer.Stop(ctx, r.dockerClient, types.ContainerStartOptions{}))) 99 | return err 100 | } 101 | 102 | r.pgExporterContainer = newContainer 103 | r.targets = append(r.targets, t) 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/postgres/postgres.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/docker/docker/api/types" 9 | "github.com/docker/docker/api/types/container" 10 | "github.com/docker/docker/api/types/mount" 11 | "github.com/docker/docker/api/types/network" 12 | "github.com/docker/docker/api/types/volume" 13 | "github.com/docker/go-connections/nat" 14 | 15 | "github.com/spinup-host/spinup/internal/dockerservice" 16 | "github.com/spinup-host/spinup/misc" 17 | ) 18 | 19 | const ( 20 | PREFIXPGCONTAINER = "spinup-postgres-" 21 | PGDATADIR = "/var/lib/postgresql/data/" 22 | ) 23 | 24 | type ContainerProps struct { 25 | Image string 26 | Name string 27 | Username string 28 | Password string 29 | Port int 30 | Memory int64 31 | CPUShares int64 32 | } 33 | 34 | func NewPostgresContainer(client dockerservice.Docker, props ContainerProps) (postgresContainer dockerservice.Container, err error) { 35 | newVolume, err := dockerservice.CreateVolume(context.Background(), client, volume.VolumeCreateBody{ 36 | Driver: "local", 37 | Labels: map[string]string{"purpose": "postgres data"}, 38 | Name: props.Name, 39 | }) 40 | if err != nil { 41 | return dockerservice.Container{}, err 42 | } 43 | // defer for cleaning volume removal 44 | defer func() { 45 | if err != nil { 46 | if errVolRemove := dockerservice.RemoveVolume(context.Background(), client, newVolume.Name); errVolRemove != nil { 47 | err = fmt.Errorf("error removing volume during failed service creation %w", err) 48 | } 49 | } 50 | }() 51 | 52 | containerName := PREFIXPGCONTAINER + props.Name 53 | newHostPort, err := nat.NewPort("tcp", strconv.Itoa(props.Port)) 54 | if err != nil { 55 | return dockerservice.Container{}, err 56 | } 57 | newContainerport, err := nat.NewPort("tcp", "5432") 58 | if err != nil { 59 | return dockerservice.Container{}, err 60 | } 61 | mounts := []mount.Mount{ 62 | { 63 | Type: mount.TypeVolume, 64 | Source: newVolume.Name, 65 | Target: "/var/lib/postgresql/data", 66 | }, 67 | } 68 | hostConfig := container.HostConfig{ 69 | PortBindings: nat.PortMap{ 70 | newContainerport: []nat.PortBinding{ 71 | { 72 | HostIP: "0.0.0.0", 73 | HostPort: newHostPort.Port(), 74 | }, 75 | }, 76 | }, 77 | NetworkMode: "default", 78 | AutoRemove: false, 79 | Mounts: mounts, 80 | Resources: container.Resources{ 81 | CPUShares: props.CPUShares, 82 | Memory: props.Memory * 1000000, 83 | }, 84 | } 85 | 86 | endpointConfig := map[string]*network.EndpointSettings{} 87 | endpointConfig[client.NetworkName] = &network.EndpointSettings{} 88 | nwConfig := network.NetworkingConfig{EndpointsConfig: endpointConfig} 89 | 90 | env := []string{ 91 | misc.StringToDockerEnvVal("POSTGRES_USER", props.Username), 92 | misc.StringToDockerEnvVal("POSTGRES_PASSWORD", props.Password), 93 | } 94 | 95 | postgresContainer = dockerservice.NewContainer( 96 | containerName, 97 | container.Config{ 98 | Image: props.Image, 99 | Env: env, 100 | }, 101 | hostConfig, 102 | nwConfig, 103 | ) 104 | return postgresContainer, nil 105 | } 106 | 107 | func ReloadPostgres(d dockerservice.Docker, execpath, datapath, containerName string) error { 108 | execConfig := types.ExecConfig{ 109 | User: "postgres", 110 | Tty: false, 111 | WorkingDir: execpath, 112 | AttachStderr: true, 113 | AttachStdout: true, 114 | Cmd: []string{"pg_ctl", "-D", datapath, "reload"}, 115 | } 116 | pgContainer, err := d.GetContainer(context.Background(), containerName) 117 | if err != nil { 118 | return fmt.Errorf("error getting container for container name %s %v", containerName, err) 119 | } 120 | if _, err := pgContainer.ExecCommand(context.Background(), d, execConfig); err != nil { 121 | return fmt.Errorf("error executing command %s %w", execConfig.Cmd[0], err) 122 | } 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /internal/service/backup_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "archive/tar" 5 | "context" 6 | "embed" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "strconv" 12 | 13 | "github.com/docker/docker/api/types" 14 | "github.com/docker/docker/api/types/container" 15 | "github.com/docker/docker/api/types/network" 16 | "github.com/docker/go-connections/nat" 17 | "github.com/pkg/errors" 18 | "github.com/robfig/cron/v3" 19 | "go.uber.org/zap" 20 | 21 | "github.com/spinup-host/spinup/config" 22 | "github.com/spinup-host/spinup/internal/dockerservice" 23 | "github.com/spinup-host/spinup/internal/metastore" 24 | "github.com/spinup-host/spinup/internal/postgres" 25 | "github.com/spinup-host/spinup/misc" 26 | "github.com/spinup-host/spinup/utils" 27 | ) 28 | 29 | // Ideally I would like to keep the modify-pghba.sh script to scripts directory. 30 | // However, Go doesn't support relative directory yet https://github.com/golang/go/issues/46056 !! 31 | 32 | //go:embed modify-pghba.sh 33 | var f embed.FS 34 | 35 | const ( 36 | tarPath = "modify-pghba.tar" 37 | PREFIXBACKUPCONTAINER = "spinup-pg-backup-" 38 | ) 39 | 40 | type BackupService struct { 41 | store metastore.Db 42 | logger *zap.Logger 43 | dockerClient dockerservice.Docker 44 | } 45 | 46 | func NewBackupService(store metastore.Db, client dockerservice.Docker, logger *zap.Logger) BackupService { 47 | return BackupService{ 48 | store: store, 49 | logger: logger, 50 | dockerClient: client, 51 | } 52 | } 53 | 54 | type BackupData struct { 55 | AwsAccessKeySecret string 56 | AwsAccessKeyId string 57 | WalgS3Prefix string 58 | PgHost string 59 | PgUsername string 60 | PgPassword string 61 | PgDatabase string 62 | } 63 | 64 | func (bs BackupService) CreateBackup(ctx context.Context, clusterID string, backupConfig metastore.BackupConfig) error { 65 | cluster, err := metastore.GetClusterByID(bs.store, clusterID) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | pgHost := postgres.PREFIXPGCONTAINER + cluster.Name 71 | var pgContainer *dockerservice.Container 72 | if pgContainer, err = bs.dockerClient.GetContainer(context.Background(), postgres.PREFIXPGCONTAINER+cluster.Name); err != nil { 73 | return errors.Wrap(err, "failed to get cluster container") 74 | } 75 | 76 | if pgContainer == nil { 77 | return errors.New("no container matched the provided ID") 78 | } 79 | 80 | minute, _ := backupConfig.Schedule["minute"].(string) 81 | min, _ := strconv.Atoi(minute) 82 | 83 | hour, _ := backupConfig.Schedule["hour"].(string) 84 | h, _ := strconv.Atoi(hour) 85 | 86 | dom, _ := backupConfig.Schedule["dom"].(string) 87 | domInt, _ := strconv.Atoi(dom) 88 | 89 | month, _ := backupConfig.Schedule["month"].(string) 90 | mon, _ := strconv.Atoi(month) 91 | 92 | dow, _ := backupConfig.Schedule["dow"].(string) 93 | dowInt, _ := strconv.Atoi(dow) 94 | 95 | insertSql := "insert into backup(clusterId, destination, bucket, second, minute, hour, dom, month, dow) values(?, ?, ?, ?, ?, ?, ?, ?, ?)" 96 | if err := metastore.InsertBackup( 97 | bs.store, 98 | insertSql, 99 | clusterID, 100 | backupConfig.Dest.Name, 101 | backupConfig.Dest.BucketName, 102 | 0, 103 | min, 104 | h, 105 | domInt, 106 | mon, 107 | dowInt, 108 | ); err != nil { 109 | return err 110 | } 111 | 112 | scriptContent, err := f.ReadFile("modify-pghba.sh") 113 | if err != nil { 114 | utils.Logger.Error("reading modify-pghba.sh file ", zap.Error(err)) 115 | } 116 | if err = updatePghba(pgContainer, bs.dockerClient, scriptContent); err != nil { 117 | return errors.Wrap(err, "failed to update pghba") 118 | } 119 | 120 | execPath := "/usr/lib/postgresql/" + strconv.Itoa(cluster.MajVersion) + "/bin/" 121 | if err = postgres.ReloadPostgres(bs.dockerClient, execPath, postgres.PGDATADIR, pgHost); err != nil { 122 | return errors.Wrap(err, "failed to relaod postgres") 123 | } 124 | scheduler := cron.New() 125 | spec := scheduleToCronExpr(backupConfig.Schedule) 126 | utils.Logger.Info("Scheduling backup at ", zap.String("spec", spec)) 127 | 128 | backupData := BackupData{ 129 | AwsAccessKeySecret: backupConfig.Dest.ApiKeySecret, 130 | AwsAccessKeyId: backupConfig.Dest.ApiKeyID, 131 | WalgS3Prefix: fmt.Sprintf("s3://%s", backupConfig.Dest.BucketName), 132 | PgHost: pgHost, 133 | PgDatabase: "postgres", 134 | PgUsername: cluster.Username, 135 | PgPassword: cluster.Password, 136 | } 137 | _, err = scheduler.AddFunc(spec, TriggerBackup(config.DefaultNetworkName, backupData)) 138 | if err != nil { 139 | utils.Logger.Error("scheduling database backup", zap.Error(err)) 140 | return err 141 | } 142 | scheduler.Start() 143 | return nil 144 | } 145 | 146 | func scheduleToCronExpr(schedule map[string]interface{}) string { 147 | spec := "" 148 | if minute, ok := schedule["minute"].(string); ok { 149 | spec = minute 150 | } else { 151 | spec += " " + "*" 152 | } 153 | if hour, ok := schedule["hour"].(string); ok { 154 | spec += " " + hour 155 | } else { 156 | spec += " " + "*" 157 | } 158 | if dom, ok := schedule["dom"].(string); ok { 159 | spec += " " + dom 160 | } else { 161 | spec += " " + "*" 162 | } 163 | if month, ok := schedule["month"].(string); ok { 164 | spec += " " + month 165 | } else { 166 | spec += " " + "*" 167 | } 168 | if dow, ok := schedule["dow"].(string); ok { 169 | spec += " " + dow 170 | } else { 171 | spec += " " + "*" 172 | } 173 | 174 | return spec 175 | } 176 | 177 | func updatePghba(c *dockerservice.Container, d dockerservice.Docker, content []byte) error { 178 | _, cleanup, err := contentToTar(content) 179 | if err != nil { 180 | return errors.Wrap(err, "failed to convert content to tar file") 181 | } 182 | defer cleanup() 183 | tr, err := os.Open(tarPath) 184 | if err != nil { 185 | return errors.Wrap(err, "error reading tar file") 186 | } 187 | defer tr.Close() 188 | err = d.Cli.CopyToContainer(context.Background(), c.ID, "/etc/postgresql", tr, types.CopyToContainerOptions{}) 189 | if err != nil { 190 | return errors.Wrap(err, "error copying file to container") 191 | } 192 | hbaFile := postgres.PGDATADIR + "pg_hba.conf" 193 | execConfig := types.ExecConfig{ 194 | User: "postgres", 195 | WorkingDir: "/etc/postgresql", 196 | AttachStderr: true, 197 | AttachStdout: true, 198 | Cmd: []string{"./modify-pghba", hbaFile}, 199 | } 200 | if _, err := c.ExecCommand(context.Background(), d, execConfig); err != nil { 201 | return errors.Wrapf(err, "error executing command '%s'", execConfig.Cmd[0]) 202 | } 203 | return nil 204 | } 205 | 206 | // contentToTar returns a tar file for given content 207 | // ref https://medium.com/learning-the-go-programming-language/working-with-compressed-tar-files-in-go-e6fe9ce4f51d 208 | func contentToTar(content []byte) (io.Writer, func(), error) { 209 | tarFile, err := os.Create(tarPath) 210 | if err != nil { 211 | log.Fatal(err) 212 | } 213 | defer tarFile.Close() 214 | tw := tar.NewWriter(tarFile) 215 | defer tw.Close() 216 | 217 | hdr := &tar.Header{ 218 | Name: "modify-pghba", 219 | Mode: 0655, 220 | Size: int64(len(content)), 221 | } 222 | if err := tw.WriteHeader(hdr); err != nil { 223 | return nil, nil, err 224 | } 225 | if _, err := tw.Write([]byte(content)); err != nil { 226 | return nil, nil, err 227 | } 228 | rmFunc := func() { 229 | os.Remove(tarPath) 230 | } 231 | return tw, rmFunc, nil 232 | } 233 | 234 | // TriggerBackup returns a closure which is being invoked by the cron 235 | func TriggerBackup(networkName string, backupData BackupData) func() { 236 | var err error 237 | dockerClient, err := dockerservice.NewDocker(config.DefaultNetworkName) 238 | if err != nil { 239 | utils.Logger.Error("Error creating client", zap.Error(err)) 240 | } 241 | var op container.ContainerCreateCreatedBody 242 | env := []string{ 243 | misc.StringToDockerEnvVal("AWS_SECRET_ACCESS_KEY", backupData.AwsAccessKeySecret), 244 | misc.StringToDockerEnvVal("AWS_ACCESS_KEY_ID", backupData.AwsAccessKeyId), 245 | misc.StringToDockerEnvVal("WALG_S3_PREFIX", backupData.WalgS3Prefix), 246 | misc.StringToDockerEnvVal("PGHOST", backupData.PgHost), 247 | misc.StringToDockerEnvVal("PGPASSWORD", backupData.PgPassword), 248 | misc.StringToDockerEnvVal("PGDATABASE", backupData.PgDatabase), 249 | misc.StringToDockerEnvVal("PGUSER", backupData.PgUsername), 250 | } 251 | 252 | // Ref: https://gist.github.com/viggy28/5b524baf005d029e4bad2ec16cb09dca 253 | // On dealing with container networking and environment variables 254 | // initialized a map 255 | endpointConfig := map[string]*network.EndpointSettings{} 256 | // setting key and value for the map. networkName=$dbname_default (eg: viggy_default) 257 | endpointConfig[networkName] = &network.EndpointSettings{} 258 | nwConfig := network.NetworkingConfig{EndpointsConfig: endpointConfig} 259 | 260 | return func() { 261 | utils.Logger.Info("starting backup") 262 | 263 | containerName := PREFIXBACKUPCONTAINER + backupData.PgHost 264 | backupContainer, err := dockerClient.GetContainer(context.TODO(), containerName) 265 | if backupContainer != nil { 266 | err = backupContainer.StartExisting(context.TODO(), dockerClient) 267 | if err != nil { 268 | utils.Logger.Error("failed to start existing walg container", zap.Error(err)) 269 | } else { 270 | utils.Logger.Info(fmt.Sprintf("reusing existing walg container: '%s'", containerName)) 271 | } 272 | } else { 273 | if err != nil { 274 | utils.Logger.Warn("could not get info for backup container, spinup will attempt to recreate it", zap.Error(err)) 275 | } 276 | walgContainer := dockerservice.NewContainer( 277 | containerName, 278 | container.Config{ 279 | Image: "spinuphost/walg:latest", 280 | Env: env, 281 | ExposedPorts: map[nat.Port]struct{}{"5432": {}}, 282 | }, 283 | container.HostConfig{NetworkMode: "default"}, 284 | nwConfig, 285 | ) 286 | op, err = walgContainer.Start(context.Background(), dockerClient) 287 | if err != nil { 288 | utils.Logger.Error("failed to start backup container", zap.Error(err)) 289 | } else { 290 | utils.Logger.Info("started backup container:", zap.String("containerId", op.ID)) 291 | } 292 | } 293 | 294 | utils.Logger.Info("Ending backup") 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /internal/service/cluster_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/pkg/errors" 8 | "go.uber.org/zap" 9 | 10 | "github.com/spinup-host/spinup/config" 11 | "github.com/spinup-host/spinup/internal/dockerservice" 12 | "github.com/spinup-host/spinup/internal/metastore" 13 | "github.com/spinup-host/spinup/internal/monitor" 14 | "github.com/spinup-host/spinup/internal/postgres" 15 | ) 16 | 17 | type Service struct { 18 | store metastore.Db 19 | dockerClient dockerservice.Docker 20 | monitorRuntime *monitor.Runtime 21 | 22 | logger *zap.Logger 23 | svcConfig config.Configuration 24 | } 25 | 26 | type ErrNoMatch struct { 27 | id string 28 | } 29 | 30 | func (e ErrNoMatch) Error() string { 31 | return fmt.Sprintf("no resource found with ID: '%s'", e.id) 32 | } 33 | 34 | func NewService(client dockerservice.Docker, store metastore.Db, mr *monitor.Runtime, logger *zap.Logger, cfg config.Configuration) Service { 35 | return Service{ 36 | store: store, 37 | dockerClient: client, 38 | monitorRuntime: mr, 39 | 40 | logger: logger, 41 | svcConfig: cfg, 42 | } 43 | } 44 | 45 | type Version struct { 46 | Maj uint 47 | Min uint 48 | } 49 | type DbCluster struct { 50 | Name string 51 | ID string 52 | Type string 53 | Port int 54 | Username string 55 | Password string 56 | } 57 | 58 | type backupConfig struct { 59 | // https://man7.org/linux/man-pages/man5/crontab.5.html 60 | Schedule map[string]interface{} 61 | Dest Destination `json:"Dest"` 62 | } 63 | 64 | type Destination struct { 65 | Name string 66 | BucketName string 67 | ApiKeyID string 68 | ApiKeySecret string 69 | } 70 | 71 | // CreateService creates a new database service alongside the needed containers. 72 | func (svc Service) CreateService(ctx context.Context, info *metastore.ClusterInfo) error { 73 | image := fmt.Sprintf("%s/%s:%d.%d", "amd64", "postgres", info.MajVersion, info.MinVersion) 74 | 75 | postgresContainerProp := postgres.ContainerProps{ 76 | Name: info.Name, 77 | Username: info.Username, 78 | Password: info.Password, 79 | Port: info.Port, 80 | Memory: info.Memory, 81 | CPUShares: info.CPU, 82 | Image: image, 83 | } 84 | 85 | pgContainer, err := postgres.NewPostgresContainer(svc.dockerClient, postgresContainerProp) 86 | if err != nil { 87 | return errors.Wrap(err, "creating new postgres container") 88 | } 89 | 90 | body, err := pgContainer.Start(ctx, svc.dockerClient) 91 | if err != nil { 92 | return errors.Wrap(err, "starting postgres container") 93 | } 94 | if len(body.Warnings) != 0 { 95 | svc.logger.Warn("container may be unhealthy", zap.Strings("warnings", body.Warnings)) 96 | } 97 | info.ClusterID = body.ID 98 | 99 | if err := metastore.InsertService(svc.store, *info); err != nil { 100 | return errors.Wrap(err, "saving cluster info to store") 101 | } 102 | 103 | if info.Monitoring == "enable" { 104 | if svc.monitorRuntime == nil { 105 | svc.monitorRuntime = monitor.NewRuntime(svc.dockerClient, monitor.WithLogger(svc.logger), monitor.WithAppConfig(svc.svcConfig)) 106 | if err := svc.monitorRuntime.BootstrapServices(ctx); err != nil { 107 | return errors.Wrap(err, "failed to start monitoring services") 108 | } 109 | } 110 | 111 | target := &monitor.Target{ 112 | ContainerName: pgContainer.Name, 113 | UserName: info.Username, 114 | Password: info.Password, 115 | Port: info.Port, 116 | } 117 | go func(target *monitor.Target) { 118 | // we use a background context since this is a goroutine and the orignal request 119 | // might have been terminated. 120 | if err := svc.addMonitorTarget(context.Background(), target); err != nil { 121 | svc.logger.Error("could not monitor target", zap.Error(err)) 122 | } 123 | return 124 | }(target) 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func (svc *Service) addMonitorTarget(ctx context.Context, target *monitor.Target) error { 131 | var err error 132 | if err = svc.monitorRuntime.AddTarget(ctx, target); err != nil { 133 | return errors.Wrap(err, "failed to add target") 134 | } 135 | return nil 136 | } 137 | 138 | // ListClusters list all clusters currently available 139 | func (svc Service) ListClusters(ctx context.Context) ([]metastore.ClusterInfo, error) { 140 | clusters, err := metastore.AllClusters(svc.store) 141 | if err != nil { 142 | return nil, err 143 | } 144 | if len(clusters) < 1 { 145 | clusters = []metastore.ClusterInfo{} 146 | } 147 | return clusters, nil 148 | } 149 | 150 | // GetClusterByID returns the specific cluster with the given ID, returns ErrNoMatch if no cluster was found. 151 | func (svc Service) GetClusterByID(ctx context.Context, clusterID string) (metastore.ClusterInfo, error) { 152 | ci, err := metastore.GetClusterByID(svc.store, clusterID) 153 | if err != nil { 154 | return ci, err 155 | } 156 | if ci.ClusterID == "" && ci.Name == "" { 157 | return ci, ErrNoMatch{ 158 | id: clusterID, 159 | } 160 | } 161 | 162 | return ci, nil 163 | } 164 | -------------------------------------------------------------------------------- /internal/service/cluster_service_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | "github.com/pkg/errors" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "go.uber.org/zap" 16 | 17 | "github.com/spinup-host/spinup/config" 18 | ds "github.com/spinup-host/spinup/internal/dockerservice" 19 | "github.com/spinup-host/spinup/internal/metastore" 20 | "github.com/spinup-host/spinup/internal/monitor" 21 | "github.com/spinup-host/spinup/tests" 22 | ) 23 | 24 | var ( 25 | maxPort = 60000 26 | minPort = 10000 27 | ) 28 | 29 | func TestCreateService(t *testing.T) { 30 | testID := uuid.New().String() 31 | ctx := context.Background() 32 | dc, err := tests.NewDockerTest(ctx, testID) 33 | require.NoError(t, err) 34 | 35 | store, path, err := newTestStore(testID) 36 | require.NoError(t, err) 37 | 38 | logger, err := newTestLogger() 39 | require.NoError(t, err) 40 | 41 | t.Cleanup(func() { 42 | _ = os.Remove(path) 43 | assert.NoError(t, dc.Cleanup()) 44 | }) 45 | 46 | rand.Seed(time.Now().UnixNano()) 47 | cfg := config.Configuration{ 48 | Common: struct { 49 | Architecture string `yaml:"architecture"` 50 | ProjectDir string `yaml:"projectDir"` 51 | Ports []int `yaml:"ports"` 52 | ClientID string `yaml:"client_id"` 53 | ClientSecret string `yaml:"client_secret"` 54 | ApiKey string `yaml:"api_key"` 55 | LogDir string `yaml:"log_dir"` 56 | LogFile string `yaml:"log_file"` 57 | Monitoring bool `yaml:"monitoring"` 58 | }(struct { 59 | Architecture string 60 | ProjectDir string 61 | Ports []int 62 | ClientID string 63 | ClientSecret string 64 | ApiKey string 65 | LogDir string 66 | LogFile string 67 | Monitoring bool 68 | }{ProjectDir: os.TempDir()}), 69 | } 70 | rt := monitor.NewRuntime(dc.Docker, monitor.WithLogger(logger), monitor.WithAppConfig(cfg)) 71 | svc := NewService(dc.Docker, store, rt, logger, cfg) 72 | 73 | t.Run("without monitoring", func(t *testing.T) { 74 | containerName := "test-db-" + uuid.New().String() 75 | ctx := context.Background() 76 | info := &metastore.ClusterInfo{ 77 | Architecture: "amd64", 78 | Type: "postgres", 79 | Host: "localhost", 80 | Name: containerName, 81 | Port: rand.Intn(maxPort-minPort) + minPort, 82 | Username: "test", 83 | Password: "test", 84 | MajVersion: 13, 85 | MinVersion: 6, 86 | } 87 | err = svc.CreateService(ctx, info) 88 | assert.NoError(t, err) 89 | 90 | pg, err := svc.dockerClient.GetContainer(ctx, "spinup-postgres-"+containerName) 91 | assert.NoError(t, err) 92 | assert.Equal(t, "running", pg.State) 93 | }) 94 | 95 | t.Run("with monitoring", func(t *testing.T) { 96 | containerName := "test-db-" + uuid.New().String() 97 | ctx := context.Background() 98 | 99 | err = svc.monitorRuntime.BootstrapServices(ctx) 100 | assert.NoError(t, err) 101 | 102 | info := &metastore.ClusterInfo{ 103 | Architecture: "amd64", 104 | Type: "postgres", 105 | Host: "localhost", 106 | Name: containerName, 107 | Port: rand.Intn(maxPort-minPort) + minPort, 108 | Username: "test", 109 | Password: "test", 110 | MajVersion: 13, 111 | MinVersion: 6, 112 | Monitoring: "enable", 113 | } 114 | exporterName := ds.PgExporterPrefix + "-" + testID 115 | currentExporter, err := svc.dockerClient.GetContainer(ctx, exporterName) 116 | assert.NoError(t, err) 117 | 118 | err = svc.CreateService(ctx, info) 119 | assert.NoError(t, err) 120 | 121 | pgC, err := svc.dockerClient.GetContainer(ctx, "spinup-postgres-"+containerName) 122 | assert.NoError(t, err) 123 | assert.Equal(t, "running", pgC.State) 124 | 125 | grafanaC, err := svc.dockerClient.GetContainer(ctx, ds.GrafanaPrefix+"-"+testID) 126 | assert.NoError(t, err) 127 | assert.Equal(t, "running", grafanaC.State) 128 | 129 | // monitoring services are started in the background, so we try for a while before giving up 130 | tries := uint32(0) 131 | maxTries := uint32(10) 132 | var newExporter *ds.Container 133 | for tries < maxTries { 134 | newExporter, err = svc.dockerClient.GetContainer(ctx, exporterName) 135 | if err == nil && newExporter.ID != currentExporter.ID && newExporter.State == "running" { 136 | break 137 | } 138 | time.Sleep(1 * time.Second) 139 | tries++ 140 | } 141 | assert.NoErrorf(t, err, "could not find new exporter container after %d retries", tries) 142 | assert.Equal(t, "running", newExporter.State) 143 | }) 144 | } 145 | 146 | func newTestStore(name string) (metastore.Db, string, error) { 147 | db := metastore.Db{} 148 | tmpDir, err := os.MkdirTemp("", "") 149 | if err != nil { 150 | return db, "", errors.Wrap(err, "create dir") 151 | } 152 | 153 | path := filepath.Join(tmpDir, name+".db") 154 | db, err = metastore.NewDb(path) 155 | if err != nil { 156 | return db, "", errors.Wrap(err, "open connection") 157 | } 158 | return db, path, nil 159 | } 160 | 161 | func newTestLogger() (*zap.Logger, error) { 162 | cfg := zap.NewProductionConfig() 163 | cfg.OutputPaths = []string{"stdout"} 164 | return cfg.Build() 165 | } 166 | -------------------------------------------------------------------------------- /internal/service/modify-pghba.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | usage() { 4 | echo $0 pg_hba_file_path 5 | 6 | printf 'To replace trust method with md5 for replication connections' 7 | } 8 | 9 | if [ "$1" = "-h" ] || [ "$1" = "--help" ] 10 | then 11 | usage 12 | exit 1 13 | else 14 | filepath="$1" # the first arg 15 | fi 16 | 17 | sed -i 's/^host replication all 127.0.0.1\/32.*/host replication all all md5/g' $filepath 18 | sed -i 's/^host replication all ::1\/128.*/host replication all all md5/g' $filepath 19 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/spinup-host/spinup/build" 8 | "github.com/spinup-host/spinup/internal/cmd" 9 | ) 10 | 11 | func main() { 12 | ctx := context.Background() 13 | bi := build.Info{ 14 | Version: build.Version, 15 | Commit: build.FullCommit, 16 | Branch: build.Branch, 17 | } 18 | if err := cmd.Execute(ctx, bi); err != nil { 19 | log.Fatal(err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/prometheus/client_golang/prometheus" 10 | "github.com/prometheus/client_golang/prometheus/promauto" 11 | "github.com/prometheus/client_golang/prometheus/promhttp" 12 | 13 | "github.com/spinup-host/spinup/config" 14 | "github.com/spinup-host/spinup/internal/metastore" 15 | ) 16 | 17 | type MetricsHandler struct { 18 | Db metastore.Db 19 | appConfig config.Configuration 20 | } 21 | 22 | func NewMetricsHandler(cfg config.Configuration) (MetricsHandler, error) { 23 | path := filepath.Join(cfg.Common.ProjectDir, "metastore.db") 24 | db, err := metastore.NewDb(path) 25 | if err != nil { 26 | return MetricsHandler{}, err 27 | } 28 | return MetricsHandler{ 29 | Db: db, 30 | appConfig: cfg, 31 | }, nil 32 | } 33 | 34 | func (m *MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 35 | if (*r).Method != "GET" { 36 | http.Error(w, "Invalid Method", http.StatusMethodNotAllowed) 37 | return 38 | } 39 | errCh := make(chan error, 1) 40 | recordMetrics(m.Db, errCh) 41 | promhttp.Handler().ServeHTTP(w, r) 42 | } 43 | 44 | var containersCreated = promauto.NewGauge(prometheus.GaugeOpts{ 45 | Name: "spinup_containers_created_gauge", 46 | Help: "The total number of containers created by spinup", 47 | }) 48 | 49 | func recordMetrics(db metastore.Db, errCh chan error) { 50 | go func() { 51 | for { 52 | time.Sleep(2 * time.Second) 53 | clusterInfos, err := metastore.AllClusters(db) 54 | if err != nil { 55 | errCh <- fmt.Errorf("couldn't read cluster info %w", err) 56 | close(errCh) 57 | } 58 | containersCreated.Set(float64(len(clusterInfos))) 59 | } 60 | }() 61 | } 62 | -------------------------------------------------------------------------------- /misc/misc.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | func minMax(array []int) (int, int) { 14 | var max int = array[0] 15 | var min int = array[0] 16 | for _, value := range array { 17 | if max < value { 18 | max = value 19 | } 20 | if min > value { 21 | min = value 22 | } 23 | } 24 | return min, max 25 | } 26 | 27 | func PortCheck(min, max int) (int, error) { 28 | for port := min; port <= max; port++ { 29 | target := fmt.Sprintf("%s:%d", "localhost", port) 30 | conn, err := net.DialTimeout("tcp", target, 3*time.Second) 31 | if err == nil { 32 | // we were able to connect, post is already used 33 | log.Printf("INFO: port %d in use", port) 34 | continue 35 | } else { 36 | if strings.Contains(err.Error(), "connect: connection refused") { 37 | // could not reach port (probably because port is not in use) 38 | log.Printf("INFO: port %d is unused", port) 39 | return port, nil 40 | } else { 41 | // could not reach port because of some other error 42 | log.Printf("INFO: failed to reach port %d and checking next port: %d", port, port+1) 43 | } 44 | defer conn.Close() 45 | } 46 | } 47 | log.Printf("WARN: all allocated ports are occupied") 48 | return 0, fmt.Errorf("error all allocated ports are occupied") 49 | } 50 | 51 | func GetContainerIdByName(name string) (string, error) { 52 | name = "name=" + name 53 | command := "docker ps -f name=" + name + " --format '{{.ID}}'" 54 | cmd := exec.Command("/bin/bash", "-c", command) 55 | // trying to directly run docker is not working correctly. ref https://stackoverflow.com/questions/53640424/exit-code-125-from-docker-when-trying-to-run-container-programmatically 56 | // cmd := exec.Command("docker", "ps", "-f", name, "--format '{{.ID}}'") 57 | var out bytes.Buffer 58 | cmd.Stdout = &out 59 | if err := cmd.Run(); err != nil { 60 | return "", err 61 | } 62 | return string(out.String()), nil 63 | } 64 | 65 | // SliceContainsString returns true if str present in s. 66 | func SliceContainsString(s []string, str string) bool { 67 | for _, v := range s { 68 | if v == str { 69 | return true 70 | } 71 | } 72 | return false 73 | } 74 | 75 | func StringToDockerEnvVal(key, val string) string { 76 | keyval := key + "=" + val 77 | return keyval 78 | } 79 | -------------------------------------------------------------------------------- /misc/misc_test.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import "testing" 4 | 5 | type SliceContainsStringData struct { 6 | Slice []string 7 | Contains string 8 | Expected bool 9 | } 10 | 11 | func TestSliceContainsString(t *testing.T) { 12 | /* actual := SliceContainsString([]string{"hello", "world"}, "world") 13 | expected := true 14 | if actual != expected { 15 | t.Errorf("actual %t, expected %t", actual, expected) 16 | } */ 17 | SliceContainsStrings := []SliceContainsStringData{ 18 | { 19 | Slice: []string{"hello", "world"}, 20 | Contains: "world", 21 | Expected: true, 22 | }, 23 | { 24 | Slice: []string{"hello", "world"}, 25 | Contains: "word", 26 | Expected: false, 27 | }, 28 | { 29 | Slice: []string{"/hello", "/world"}, 30 | Contains: "/world", 31 | Expected: true, 32 | }, 33 | } 34 | for _, iter := range SliceContainsStrings { 35 | if actual := SliceContainsString(iter.Slice, iter.Contains); actual != iter.Expected { 36 | t.Errorf("actual %t, expected %t", actual, iter.Expected) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /openapi/cluster.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "github.com/spinup-host/spinup/api" 5 | "github.com/spinup-host/spinup/internal/metastore" 6 | ) 7 | 8 | // swagger:route GET /listcluster cluster listCluster 9 | // List all created clusters. 10 | // 11 | // Responses: 12 | // 200: listClusterResponse 13 | // 401: unauthorizedResponse 14 | 15 | // listClusterResponseWrapper wraps a successful response when listing clusters. 16 | // swagger:model listClusterResponse 17 | type listClusterResponseWrapper struct { 18 | // in:body 19 | Data []metastore.ClusterInfo `json:"data"` 20 | } 21 | 22 | // unauthorizedResponseWrapper wraps an unauthorized response. 23 | // swagger:model unauthorizedResponse 24 | type unauthorizedResponseWrapper struct { 25 | // in:body 26 | Message string `json:"message"` 27 | } 28 | 29 | // swagger:route Post /create cluster createCluster 30 | // Create a new cluster. 31 | // 32 | // Responses: 33 | // 200: createClusterResponse 34 | // 401: unauthorizedResponse 35 | 36 | // swagger:parameters createCluster 37 | type createClusterParamsWrapper struct { 38 | // Parameters for create the new cluster 39 | // in:body 40 | Body api.Cluster 41 | } 42 | 43 | // createClusterResponseWrapper wraps a successful response after creating a cluster endpoint. 44 | // swagger:model createClusterResponse 45 | type createClusterResponseWrapper struct { 46 | // in:body 47 | Data metastore.ClusterInfo `json:"data"` 48 | } 49 | -------------------------------------------------------------------------------- /openapi/docs.go: -------------------------------------------------------------------------------- 1 | // Package classification Spinup API. 2 | // 3 | // API definitions for Spinup 4 | // 5 | // Schemes: http, https 6 | // BasePath: / 7 | // Version: 0.9.1 8 | // Host: localhost:4434 9 | // 10 | // Consumes: 11 | // - application/json 12 | // 13 | // Produces: 14 | // - application/json 15 | // 16 | // Security: 17 | // - api_key 18 | // - oauth2 19 | // 20 | // SecurityDefinitions: 21 | // api_key: 22 | // type: apiKey 23 | // name: x-api-key 24 | // in: header 25 | // oauth2: 26 | // type: oauth2 27 | // authorizationUrl: /auth 28 | // tokenUrl: /auth/token 29 | // in: header 30 | // 31 | // swagger:meta 32 | 33 | package openapi 34 | -------------------------------------------------------------------------------- /openapi/swagger.yaml: -------------------------------------------------------------------------------- 1 | definitions: 2 | Cluster: 3 | description: |- 4 | Cluster is used to parse request from JSON payload 5 | todo merge with metastore.ClusterInfo 6 | properties: 7 | architecture: 8 | description: one of arm64v8 or arm32v7 or amd64 9 | type: string 10 | x-go-name: Architecture 11 | db: 12 | $ref: '#/definitions/dbCluster' 13 | userId: 14 | type: string 15 | x-go-name: UserID 16 | version: 17 | $ref: '#/definitions/version' 18 | type: object 19 | x-go-package: github.com/spinup-host/spinup/api 20 | ClusterInfo: 21 | properties: 22 | architecture: 23 | description: one of arm64v8 or arm32v7 or amd64 24 | type: string 25 | x-go-name: Architecture 26 | backup: 27 | $ref: '#/definitions/backupConfig' 28 | backup_enabled: 29 | type: boolean 30 | x-go-name: BackupEnabled 31 | cluster_id: 32 | type: string 33 | x-go-name: ClusterID 34 | cpu: 35 | format: int64 36 | type: integer 37 | x-go-name: CPU 38 | host: 39 | type: string 40 | x-go-name: Host 41 | id: 42 | format: int64 43 | type: integer 44 | x-go-name: ID 45 | majversion: 46 | format: int64 47 | type: integer 48 | x-go-name: MajVersion 49 | memory: 50 | format: int64 51 | type: integer 52 | x-go-name: Memory 53 | minversion: 54 | format: int64 55 | type: integer 56 | x-go-name: MinVersion 57 | monitoring: 58 | type: string 59 | x-go-name: Monitoring 60 | name: 61 | type: string 62 | x-go-name: Name 63 | password: 64 | type: string 65 | x-go-name: Password 66 | port: 67 | format: int64 68 | type: integer 69 | x-go-name: Port 70 | type: 71 | type: string 72 | x-go-name: Type 73 | username: 74 | type: string 75 | x-go-name: Username 76 | type: object 77 | x-go-package: github.com/spinup-host/spinup/internal/metastore 78 | Destination: 79 | properties: 80 | ApiKeyID: 81 | type: string 82 | ApiKeySecret: 83 | type: string 84 | BucketName: 85 | type: string 86 | Name: 87 | type: string 88 | type: object 89 | x-go-package: github.com/spinup-host/spinup/internal/metastore 90 | backupConfig: 91 | properties: 92 | Dest: 93 | $ref: '#/definitions/Destination' 94 | Schedule: 95 | additionalProperties: 96 | type: object 97 | description: https://man7.org/linux/man-pages/man5/crontab.5.html 98 | type: object 99 | type: object 100 | x-go-package: github.com/spinup-host/spinup/internal/metastore 101 | createClusterResponse: 102 | properties: 103 | data: 104 | $ref: '#/definitions/ClusterInfo' 105 | title: createClusterResponseWrapper wraps a successful response after creating 106 | a cluster endpoint. 107 | type: object 108 | x-go-name: createClusterResponseWrapper 109 | x-go-package: github.com/spinup-host/spinup/openapi 110 | dbCluster: 111 | properties: 112 | cpu: 113 | format: int64 114 | type: integer 115 | x-go-name: CPU 116 | id: 117 | type: string 118 | x-go-name: ID 119 | memory: 120 | format: int64 121 | type: integer 122 | x-go-name: Memory 123 | monitoring: 124 | type: string 125 | x-go-name: Monitoring 126 | name: 127 | type: string 128 | x-go-name: Name 129 | password: 130 | type: string 131 | x-go-name: Password 132 | type: 133 | type: string 134 | x-go-name: Type 135 | username: 136 | type: string 137 | x-go-name: Username 138 | type: object 139 | x-go-package: github.com/spinup-host/spinup/api 140 | listClusterResponse: 141 | properties: 142 | data: 143 | description: in:body 144 | items: 145 | $ref: '#/definitions/ClusterInfo' 146 | type: array 147 | x-go-name: Data 148 | title: listClusterResponseWrapper wraps a successful response when listing clusters. 149 | type: object 150 | x-go-name: listClusterResponseWrapper 151 | x-go-package: github.com/spinup-host/spinup/openapi 152 | unauthorizedResponse: 153 | properties: 154 | message: 155 | description: in:body 156 | type: string 157 | x-go-name: Message 158 | title: unauthorizedResponseWrapper wraps an unauthorized response. 159 | type: object 160 | x-go-name: unauthorizedResponseWrapper 161 | x-go-package: github.com/spinup-host/spinup/openapi 162 | version: 163 | properties: 164 | maj: 165 | format: uint64 166 | type: integer 167 | x-go-name: Maj 168 | min: 169 | format: uint64 170 | type: integer 171 | x-go-name: Min 172 | type: object 173 | x-go-package: github.com/spinup-host/spinup/api 174 | info: {} 175 | paths: 176 | /create: 177 | post: 178 | operationId: createCluster 179 | parameters: 180 | - description: Parameters for create the new cluster 181 | in: body 182 | name: Body 183 | schema: 184 | $ref: '#/definitions/Cluster' 185 | responses: 186 | "200": 187 | description: createClusterResponse 188 | schema: 189 | $ref: '#/definitions/createClusterResponse' 190 | "401": 191 | description: unauthorizedResponse 192 | schema: 193 | $ref: '#/definitions/unauthorizedResponse' 194 | summary: Create a new cluster. 195 | tags: 196 | - cluster 197 | /listcluster: 198 | get: 199 | operationId: listCluster 200 | responses: 201 | "200": 202 | description: listClusterResponse 203 | schema: 204 | $ref: '#/definitions/listClusterResponse' 205 | "401": 206 | description: unauthorizedResponse 207 | schema: 208 | $ref: '#/definitions/unauthorizedResponse' 209 | summary: List all created clusters. 210 | tags: 211 | - cluster 212 | swagger: "2.0" 213 | -------------------------------------------------------------------------------- /scripts/install-spinup-dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | for req in "docker" "openssl" "npm" "jq"; 4 | do 5 | if [ ! $(command -v "$req") ]; then 6 | echo "Cannot find or execute '$req' command" 7 | exit 1 8 | fi 9 | done 10 | 11 | if [ -z "$CLIENT_ID" ]; then 12 | echo "No value for environment variable CLIENT_ID" 13 | fi 14 | 15 | if [ -z "$CLIENT_SECRET" ]; then 16 | echo "No value for environment variable CLIENT_SECRET" 17 | fi 18 | 19 | if [ -z "$SPINUP_API_KEY" ]; then 20 | echo "No value for environment variable SPINUP_API_KEY. Setting it to default value of spinup" 21 | SPINUP_API_KEY="spinup" 22 | fi 23 | 24 | SPINUP_DIR=${SPINUP_DIR:-"${HOME}/.local/spinup"} 25 | if [ -z "$VERSION" ]; then 26 | echo "Fetching latest Spinup version..." 27 | SPINUP_VERSION=$(curl --silent "https://api.github.com/repos/spinup-host/spinup/releases" | jq -r 'first | .tag_name') 28 | else 29 | SPINUP_VERSION=$VERSION 30 | fi 31 | 32 | OS=$(go env GOOS) 33 | ARCH=$(go env GOARCH) 34 | PLATFORM="${OS}-${ARCH}" 35 | 36 | SPINUP_PACKAGE="spinup-${SPINUP_VERSION}-${OS}-${ARCH}.tar.gz" 37 | SPINUP_TMP_DIR="/tmp/spinup-install" 38 | 39 | mkdir -p ${SPINUP_DIR} 40 | mkdir -p ${SPINUP_TMP_DIR} 41 | 42 | echo "git clone --depth=1 --branch=${SPINUP_VERSION} https://github.com/spinup-host/spinup.git ${SPINUP_TMP_DIR}/spinup-api" 43 | git clone --depth=1 --branch=${SPINUP_VERSION} https://github.com/spinup-host/spinup.git ${SPINUP_TMP_DIR}/spinup-api 44 | cd ${SPINUP_TMP_DIR}/spinup-api 45 | go build -o spinup-backend ./main.go 46 | ./spinup-backend version 47 | cp ${SPINUP_TMP_DIR}/spinup-api/spinup-backend ${SPINUP_DIR}/spinup 48 | 49 | git clone --depth=1 https://github.com/spinup-host/spinup-dash.git ${SPINUP_TMP_DIR}/spinup-dash 50 | cd ${SPINUP_TMP_DIR}/spinup-dash 51 | # setup env variables for dashboard's npm build 52 | cat >.env <<-EOF 53 | REACT_APP_CLIENT_ID=${CLIENT_ID} 54 | REACT_APP_REDIRECT_URI=http://localhost:3000/login 55 | REACT_APP_SERVER_URI=http://localhost:4434 56 | REACT_APP_GITHUB_SERVER=http://localhost:4434/githubAuth 57 | REACT_APP_LIST_URI=http://localhost:4434/listcluster 58 | REACT_APP_URL=https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=http://localhost:3000/login 59 | EOF 60 | cat .env 61 | npm install --ignore-scripts 62 | npm run build 63 | rm -rf ${SPINUP_DIR}/spinup-dash 64 | cp -a -R ${SPINUP_TMP_DIR}/spinup-dash/build ${SPINUP_DIR}/spinup-dash 65 | 66 | cd ${SPINUP_DIR} 67 | # preserve existing config file, or create a new one if none exists 68 | 69 | CONFIG_FILE="${SPINUP_DIR}/config.yaml" 70 | if [[ -f "$CONFIG_FILE" ]]; then 71 | echo "Found existing configuration file at ${CONFIG_FILE}." 72 | else 73 | cat >config.yaml <<-EOF 74 | common: 75 | ports: [ 76 | 5432, 5433, 5434, 5435, 5436, 5437 77 | ] 78 | db_metric_ports: [ 79 | 55432, 55433, 55434, 55435, 55436, 55437 80 | ] 81 | architecture: amd64 82 | projectDir: ${SPINUP_DIR} 83 | client_id: ${CLIENT_ID} 84 | client_secret: ${CLIENT_SECRET} 85 | api_key: ${SPINUP_API_KEY} 86 | EOF 87 | fi 88 | 89 | openssl genrsa -out ${SPINUP_DIR}/app.rsa 4096 90 | openssl rsa -in ${SPINUP_DIR}/app.rsa -pubout > ${SPINUP_DIR}/app.rsa.pub 91 | 92 | rm -rf ${SPINUP_TMP_DIR} 93 | echo "Setup complete! To run spinup from your terminal, add ${SPINUP_DIR} to your shell path" -------------------------------------------------------------------------------- /scripts/install-spinup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | for req in "docker" "openssl" "npm" "jq"; 4 | do 5 | if [ ! $(command -v "$req") ]; then 6 | echo "Cannot find or execute '$req' command" 7 | exit 1 8 | fi 9 | done 10 | 11 | if [ -z "$CLIENT_ID" ]; then 12 | echo "No value for environment variable CLIENT_ID" 13 | fi 14 | 15 | if [ -z "$CLIENT_SECRET" ]; then 16 | echo "No value for environment variable CLIENT_SECRET" 17 | fi 18 | 19 | if [ -z "$SPINUP_API_KEY" ]; then 20 | echo "No value for environment variable SPINUP_API_KEY. Setting it to default value of spinup" 21 | SPINUP_API_KEY="spinup" 22 | fi 23 | 24 | SPINUP_DIR=${SPINUP_DIR:-"${HOME}/.local/spinup"} 25 | echo "Fetching latest Spinup version..." 26 | SPINUP_VERSION=$(curl --silent "https://api.github.com/repos/spinup-host/spinup/releases" | jq -r 'first | .tag_name') 27 | 28 | OS=$(go env GOOS) 29 | ARCH=$(go env GOARCH) 30 | PLATFORM="${OS}-${ARCH}" 31 | API_DL_URL="https://github.com/spinup-host/spinup/releases/download/${SPINUP_VERSION}/spinup-backend-${SPINUP_VERSION}-${PLATFORM}.tar.gz" 32 | 33 | SPINUP_PACKAGE="spinup-${SPINUP_VERSION}-${OS}-${ARCH}.tar.gz" 34 | SPINUP_TMP_DIR="/tmp/spinup-install" 35 | 36 | mkdir -p ${SPINUP_DIR} 37 | mkdir -p ${SPINUP_TMP_DIR} 38 | 39 | curl -LSs ${API_DL_URL} -o ${SPINUP_TMP_DIR}/${SPINUP_PACKAGE} 40 | tar xzvf ${SPINUP_TMP_DIR}/${SPINUP_PACKAGE} -C "${SPINUP_TMP_DIR}/" 41 | rm -f ${SPINUP_TMP_DIR}/${SPINUP_PACKAGE} 42 | 43 | ${SPINUP_TMP_DIR}/spinup-backend version 44 | cp ${SPINUP_TMP_DIR}/spinup-backend ${SPINUP_DIR}/spinup 45 | 46 | git clone --depth=1 https://github.com/spinup-host/spinup-dash.git ${SPINUP_TMP_DIR}/spinup-dash 47 | cd ${SPINUP_TMP_DIR}/spinup-dash 48 | # setup env variables for dashboard's npm build 49 | cat >.env <<-EOF 50 | REACT_APP_CLIENT_ID=${CLIENT_ID} 51 | REACT_APP_REDIRECT_URI=http://localhost:3000/login 52 | REACT_APP_SERVER_URI=http://localhost:4434 53 | REACT_APP_GITHUB_SERVER=http://localhost:4434/githubAuth 54 | REACT_APP_URL=https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=http://localhost:3000/login 55 | EOF 56 | cat .env 57 | npm install --ignore-scripts 58 | npm run build 59 | rm -rf ${SPINUP_DIR}/spinup-dash 60 | cp -a -R ${SPINUP_TMP_DIR}/spinup-dash/build ${SPINUP_DIR}/spinup-dash 61 | 62 | cd ${SPINUP_DIR} 63 | # preserve existing config file, or create a new one if none exists 64 | 65 | CONFIG_FILE="${SPINUP_DIR}/config.yaml" 66 | if [[ -f "$CONFIG_FILE" ]]; then 67 | echo "Found existing configuration file at ${CONFIG_FILE}." 68 | else 69 | cat >config.yaml <<-EOF 70 | common: 71 | ports: [ 72 | 5432, 5433, 5434, 5435, 5436, 5437 73 | ] 74 | db_metric_ports: [ 75 | 55432, 55433, 55434, 55435, 55436, 55437 76 | ] 77 | architecture: amd64 78 | projectDir: ${SPINUP_DIR} 79 | client_id: ${CLIENT_ID} 80 | client_secret: ${CLIENT_SECRET} 81 | api_key: ${SPINUP_API_KEY} 82 | EOF 83 | fi 84 | 85 | openssl genrsa -out ${SPINUP_DIR}/app.rsa 4096 86 | openssl rsa -in ${SPINUP_DIR}/app.rsa -pubout > ${SPINUP_DIR}/app.rsa.pub 87 | 88 | rm -rf ${SPINUP_TMP_DIR} 89 | echo "Setup complete! To run spinup from your terminal, add ${SPINUP_DIR} to your shell path" -------------------------------------------------------------------------------- /spinup-backend.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Spinup backend service 3 | ConditionPathExists=/home/pi/spinup/spinup-backend 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=pi 9 | Group=pi 10 | LimitNOFILE=1024 11 | 12 | Restart=on-failure 13 | RestartSec=10 14 | startLimitIntervalSec=60 15 | EnvironmentFile=/home/pi/spinup/spinup-backend/spinup.env 16 | WorkingDirectory=/home/pi/spinup/spinup-backend 17 | ExecStart=/home/pi/spinup/spinup-backend/spinup 18 | 19 | [Install] 20 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /tests/dockertest.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/docker/docker/api/types" 8 | "github.com/docker/docker/api/types/container" 9 | "github.com/docker/docker/api/types/filters" 10 | "github.com/pkg/errors" 11 | "go.uber.org/multierr" 12 | 13 | ds "github.com/spinup-host/spinup/internal/dockerservice" 14 | ) 15 | 16 | // DockerTest wraps docker service to be used in tests 17 | type DockerTest struct { 18 | ds.Docker 19 | } 20 | 21 | func NewDockerTest(ctx context.Context, networkName string) (DockerTest, error) { 22 | dc, err := ds.NewDocker(networkName) 23 | if err != nil { 24 | return DockerTest{}, err 25 | } 26 | 27 | _, err = dc.CreateNetwork(ctx) 28 | if err != nil { 29 | return DockerTest{}, errors.Wrap(err, "create network") 30 | } 31 | return DockerTest{ 32 | Docker: dc, 33 | }, nil 34 | } 35 | 36 | // Cleanup removes all containers and volumes in the docker network, and removes the network itself. 37 | func (dt DockerTest) Cleanup() error { 38 | ctx := context.Background() 39 | filter := filters.NewArgs() 40 | filter.Add("network", dt.NetworkName) 41 | 42 | containers, err := dt.Cli.ContainerList(ctx, types.ContainerListOptions{All: true, Filters: filter}) 43 | if err != nil { 44 | return errors.Wrap(err, "list containers") 45 | } 46 | 47 | var cleanupErr error 48 | for _, c := range containers { 49 | if err = dt.Cli.ContainerStop(ctx, c.ID, container.StopOptions{Timeout: nil}); err != nil { 50 | if strings.Contains(err.Error(), "No such container") { 51 | continue 52 | } 53 | cleanupErr = multierr.Append(cleanupErr, errors.Wrap(err, "stop container")) 54 | } 55 | if err = dt.Cli.ContainerRemove(ctx, c.ID, types.ContainerRemoveOptions{}); err != nil { 56 | if strings.Contains(err.Error(), "No such container") { 57 | continue 58 | } 59 | cleanupErr = multierr.Append(cleanupErr, errors.Wrap(err, "remove container")) 60 | } 61 | 62 | // cleanup its volumes 63 | for _, mount := range c.Mounts { 64 | if mount.Type == "volume" { 65 | if err = dt.Cli.VolumeRemove(ctx, mount.Name, true); err != nil { 66 | cleanupErr = multierr.Append(cleanupErr, errors.Wrap(err, "remove volume")) 67 | } 68 | } 69 | } 70 | } 71 | 72 | if err = dt.Cli.NetworkRemove(ctx, dt.NetworkName); err != nil { 73 | cleanupErr = multierr.Append(cleanupErr, errors.Wrap(err, "remove network")) 74 | } 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /testutils/testutil.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import "github.com/spinup-host/spinup/config" 4 | 5 | // GetConfig returns a test configuration that can be used in tests. 6 | func GetConfig() config.Configuration { 7 | return config.Configuration{} 8 | } 9 | -------------------------------------------------------------------------------- /utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "go.uber.org/zap" 9 | "go.uber.org/zap/zapcore" 10 | ) 11 | 12 | var Logger *zap.Logger 13 | 14 | // InitializeLogger sets up a log file to write spinup logs to. 15 | func InitializeLogger(logDir string, fileName string) { 16 | loggingFilePath := "" 17 | 18 | config := zap.NewProductionEncoderConfig() 19 | if logDir == "" { 20 | homeDir, _ := os.UserHomeDir() 21 | loggingFilePath = homeDir 22 | } else { 23 | loggingFilePath = logDir 24 | } 25 | if fileName == "" { 26 | loggingFilePath += "/Spinup.log" 27 | } else { 28 | loggingFilePath += "/" + fileName 29 | } 30 | 31 | log.Println(fmt.Sprintf("using log file %s\n", loggingFilePath)) 32 | config.EncodeTime = zapcore.ISO8601TimeEncoder 33 | fileEncoder := zapcore.NewJSONEncoder(config) 34 | consoleEncoder := zapcore.NewConsoleEncoder(config) 35 | logFile, _ := os.OpenFile(loggingFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 36 | writer := zapcore.AddSync(logFile) 37 | defaultLogLevel := zapcore.DebugLevel 38 | core := zapcore.NewTee( 39 | zapcore.NewCore(fileEncoder, writer, defaultLogLevel), 40 | zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), defaultLogLevel), 41 | ) 42 | Logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) 43 | } 44 | --------------------------------------------------------------------------------