diff --git a/.circleci/brew-deploy.sh b/.circleci/brew-deploy.sh
index 948eb99fd..d352dc23c 100755
--- a/.circleci/brew-deploy.sh
+++ b/.circleci/brew-deploy.sh
@@ -2,8 +2,6 @@
set -e
# Install the latest circleci from homebrew
-git -C /usr/local/Homebrew/Library/Taps/homebrew/homebrew-core fetch --unshallow
-git -C /usr/local/Homebrew/Library/Taps/homebrew/homebrew-cask fetch --unshallow
brew update
VERSION=$("$DESTDIR"/circleci version)
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 2432b44d0..79dc8cb66 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,19 +1,18 @@
version: 2.1
orbs:
- codecov: codecov/codecov@1.1.3
shellcheck: circleci/shellcheck@1.2.0
- windows: circleci/windows@2.2.0
+ windows: circleci/windows@5.0.0
executors:
go:
docker:
- - image: circleci/golang:1.17
+ - image: cimg/go:1.20
environment:
CGO_ENABLED: 0
mac:
macos:
- xcode: 11.3.1
+ xcode: 12.5.1
environment:
CGO_ENABLED: 0
HOMEBREW_NO_AUTO_UPDATE: 1
@@ -23,11 +22,10 @@ commands:
force-http-1:
steps:
- run:
- # Uploading to codecov has been failing due to HTTP 2.0 issues.
# https://app.circleci.com/jobs/github/CircleCI-Public/circleci-cli/6480
# curl: (92) HTTP/2 stream 0 was not closed cleanly: PROTOCOL_ERROR (err 1)
# The issue seems to be on the server-side, so force HTTP 1.1
- name: "cURL: Force HTTP 1.1"
+ name: 'cURL: Force HTTP 1.1'
command: echo '--http1.1' >> ~/.curlrc
build-docker-image:
steps:
@@ -51,7 +49,7 @@ commands:
- persist_to_workspace:
root: .
paths:
- - "dist"
+ - 'dist'
- store_artifacts:
path: ./dist
destination: dist
@@ -62,7 +60,7 @@ commands:
default: https://github.com/goreleaser/goreleaser/releases/download/v0.127.0/goreleaser_amd64.deb
steps:
- restore_cache:
- keys: [v4-goreleaser-]
+ keys: [v5-goreleaser-]
- run:
name: Install GoReleaser
command: |
@@ -72,7 +70,7 @@ commands:
gomod:
steps:
- restore_cache:
- keys: ["v2-gomod-{{ arch }}-"]
+ keys: ['v3-gomod-{{ arch }}-']
- run:
name: Download go module dependencies
command: go mod download
@@ -84,17 +82,22 @@ commands:
jobs:
test_windows:
- executor: windows/default
+ executor:
+ name: windows/default
+ shell: bash --login -eo pipefail
steps:
- run: git config --global core.autocrlf false
- checkout
- - run: setx GOPATH %USERPROFILE%\go
- - run: go get gotest.tools/gotestsum
- run: mkdir test_results
+
- run:
name: Run tests
command: |
- C:\Users\circleci\go\bin\gotestsum.exe --junitfile test_results/windows.xml
+ export GOBIN=/c/go/bin
+ export PATH=$GOBIN:$PATH
+ export TESTING="true"
+ go install gotest.tools/gotestsum@latest
+ gotestsum --junitfile test_results/windows.xml
- store_test_results:
path: test_results
- store_artifacts:
@@ -104,8 +107,9 @@ jobs:
steps:
- checkout
- run: |
- brew install go@1.13
- echo 'export PATH="/usr/local/opt/go@1.13/bin:$PATH"' >> ~/.bash_profile
+ brew update
+ brew install go@1.20
+ echo 'export PATH="/usr/local/opt/go@1.20/bin:$PATH"' >> ~/.bash_profile
- gomod
- run: make test
build:
@@ -117,7 +121,7 @@ jobs:
- persist_to_workspace:
root: .
paths:
- - "build"
+ - 'build'
cucumber:
docker:
- image: cimg/ruby:2.7
@@ -126,7 +130,7 @@ jobs:
- attach_workspace:
at: .
- run:
- name: "Install CLI tool from workspace"
+ name: 'Install CLI tool from workspace'
command: sudo cp ~/project/build/linux/amd64/circleci /usr/local/bin/
- run:
command: bundle install
@@ -134,7 +138,8 @@ jobs:
- run:
command: bundle exec cucumber
working_directory: integration_tests
-
+ environment:
+ TESTING: "true"
test:
executor: go
steps:
@@ -154,14 +159,16 @@ jobs:
- store_artifacts:
path: ./coverage.txt
destination: coverage.txt
- - codecov/upload:
- file: coverage.txt
docs:
executor: go
steps:
- checkout
- - run: sudo apt-get install pandoc
+ - run:
+ name: Install pandoc
+ command: |
+ sudo apt-get update
+ sudo apt-get install pandoc
- gomod
- run: go run main.go usage
- store_artifacts:
@@ -171,12 +178,12 @@ jobs:
- run: ./.circleci/deploy-gh-pages.sh
lint:
- executor: go
+ docker:
+ - image: golangci/golangci-lint:v1.46-alpine
+ resource_class: large
steps:
- checkout
- - run: make install-lint
- - run: make build
- - run: make lint
+ - run: golangci-lint run
deploy-test:
executor: go
@@ -263,9 +270,9 @@ jobs:
brew-deploy:
executor: mac
environment:
- - USER: circleci
- - TRAVIS: circleci
- - DESTDIR: /Users/distiller/dest
+ USER: circleci
+ TRAVIS: circleci
+ DESTDIR: /Users/distiller/dest
steps:
- checkout
- force-http-1
@@ -328,6 +335,8 @@ workflows:
- brew-deploy:
requires:
- run-brew-deploy-gate
+ context:
+ - devex-release
- deploy:
requires:
- test
@@ -338,4 +347,6 @@ workflows:
- shellcheck/check
filters:
branches:
- only: master
+ only: main
+ context:
+ - devex-release
diff --git a/.circleci/install-lint.sh b/.circleci/install-lint.sh
deleted file mode 100755
index 920695e39..000000000
--- a/.circleci/install-lint.sh
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/usr/bin/env bash
-
-set -o errexit
-set -o pipefail
-set -o nounset
-
-function error() {
- echo "An error occured installing golangci-lint."
-}
-
-trap error SIGINT
-
-curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.35.2
-
-command -v ./bin/golangci-lint
diff --git a/.circleci/install-packr.sh b/.circleci/install-packr.sh
deleted file mode 100755
index e98130fd7..000000000
--- a/.circleci/install-packr.sh
+++ /dev/null
@@ -1,46 +0,0 @@
-#!/usr/bin/env bash
-
-set -o errexit
-set -o pipefail
-set -o nounset
-
-PACKR_VERSION="2.2.0"
-RELEASE_URL="https://github.com/gobuffalo/packr/releases/download"
-DESTDIR="${DESTDIR:-$PWD/bin}"
-
-SCRATCH=$(mktemp -d)
-cd "$SCRATCH"
-
-function error() {
- echo "An error occured installing the tool."
- echo "The contents of the directory $SCRATCH have been left in place to help to debug the issue."
-}
-
-trap error SIGINT
-
-SUPPORTED_ARCHS=(darwin_386 darwin_amd64 linux_386 linux_amd64)
-
-function install_arch() {
- ARCH=$1
- PACKR_RELEASE_URL="${RELEASE_URL}/v${PACKR_VERSION}/packr_${PACKR_VERSION}_${ARCH}.tar.gz"
-
- echo "Fetching packr from $PACKR_RELEASE_URL"
-
- curl --retry 3 --fail --location "$PACKR_RELEASE_URL" | tar -xz
-
- echo "Installing packr for $ARCH to $DESTDIR"
- mkdir "$DESTDIR/$ARCH"
- mv packr2 "$DESTDIR/$ARCH"
- chmod +x "$DESTDIR/$ARCH/packr2"
-
- command -v "$DESTDIR/$ARCH/packr2"
-}
-
-for ARCH in "${SUPPORTED_ARCHS[@]}"; do
- install_arch "$ARCH"
-done
-
-# Delete the working directory when the install was successful.
-rm -r "$SCRATCH"
-
-exit 0
diff --git a/.circleci/lint.sh b/.circleci/lint.sh
deleted file mode 100644
index f9c5aedac..000000000
--- a/.circleci/lint.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/usr/bin/env bash
-
-set -o errexit
-set -o pipefail
-set -o nounset
-
-function error() {
- echo "An error occured running golangci-lint."
- echo "Have you run \"make install-lint\"?"
-}
-
-trap error SIGINT
-
-./bin/golangci-lint run
diff --git a/.circleci/pack.sh b/.circleci/pack.sh
deleted file mode 100755
index 319cb1f09..000000000
--- a/.circleci/pack.sh
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/usr/bin/env bash
-
-set -o errexit
-set -o pipefail
-set -o nounset
-
-export GO111MODULE=on
-
-function error() {
- echo "An error occured running packr."
-}
-
-trap error SIGINT
-
-function get_arch_type() {
- if [[ $(uname -m) == "i686" ]]; then
- echo "386"
- elif [[ $(uname -m) == "x86_64" ]]; then
- echo "amd64"
- fi
-}
-
-function get_arch_base() {
- if [[ "$OSTYPE" == "linux-gnu" ]]; then
- echo "linux"
- elif [[ "$OSTYPE" == "darwin"* ]]; then
- echo "darwin"
- fi
-}
-
-ARCH="$(get_arch_base)_$(get_arch_type)"
-CMD="bin/$ARCH/packr2"
-
-command -v "$CMD"
-
-./"$CMD" build
-
-exit 0
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..0385e3430
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 04772c924..ec9fce118 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,2 +1,5 @@
-* @CircleCI-Public/EcoSystem
-*orb*.go @CircleCI-Public/CPEng @CircleCI-Public/EcoSystem
\ No newline at end of file
+* @CircleCI-Public/developer-experience
+*orb*.go @CircleCI-Public/orb-publishers @CircleCI-Public/developer-experience
+
+/api/runner @CircleCI-Public/runner
+/cmd/runner @CircleCI-Public/runner
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 000000000..e0775f58e
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,54 @@
+# Checklist
+
+=========
+
+- [ ] I have performed a self-review of my own code
+- [ ] I have commented my code, particularly in hard-to-understand areas
+- [ ] I have checked for similar issues and haven't found anything relevant.
+- [ ] This is not a security issue (which should be reported here: https://circleci.com/security/)
+- [ ] I have read [Contribution Guidelines](https://github.com/CircleCI-Public/circleci-cli/blob/main/CONTRIBUTING.md).
+
+### Internal Checklist
+- [ ] I am requesting a review from my own team as well as the owning team
+- [ ] I have a plan in place for the monitoring of the changes that I am making (this can include new monitors, logs to be aware of, etc...)
+
+## Changes
+
+=======
+
+- Put itemized changes here, preferably in imperative mood, i.e. "Add
+ `functionA` to `fileB`"
+
+## Rationale
+
+=========
+
+What was the overarching product goal of this PR as well as any pertinent
+history of changes
+
+## Considerations
+
+==============
+
+Why you made some of the technical decisions that you made, especially if the
+reasoning is not immediately obvious
+
+## Screenshots
+
+============
+
+
Before
+Image or [gif](https://giphy.com/apps/giphycapture)
+
+After
+Image or gif where change can be clearly seen
+
+## **Here are some helpful tips you can follow when submitting a pull request:**
+
+1. Fork [the repository](https://github.com/CircleCI-Public/circleci-cli) and create your branch from `main`.
+2. Run `make build` in the repository root.
+3. If you've fixed a bug or added code that should be tested, add tests!
+4. Ensure the test suite passes (`make test`).
+5. The `--debug` flag is often helpful for debugging HTTP client requests and responses.
+6. Format your code with [gofmt](https://golang.org/cmd/gofmt/).
+7. Make sure your code lints (`make lint`). Note: This requires Docker to run inside a local job.
diff --git a/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST.md b/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST.md
deleted file mode 100644
index 27d86a315..000000000
--- a/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST.md
+++ /dev/null
@@ -1,13 +0,0 @@
-- [ ] I have read [Contribution Guidelines](https://github.com/CircleCI-Public/circleci-cli/blob/master/CONTRIBUTING.md).
-- [ ] I have checked for similar issues and haven't found anything relevant.
-- [ ] This is not a security issue (which should be reported here: https://circleci.com/security/)
-
-**Here are some helpful tips you can follow when submitting a pull request:**
-
-1. Fork [the repository](https://github.com/CircleCI-Public/circleci-cli) and create your branch from `master`.
-2. Run `make build` in the repository root.
-3. If you've fixed a bug or added code that should be tested, add tests!
-4. Ensure the test suite passes (`make test`).
-5. The `--debug` flag is often helpful for debugging HTTP client requests and responses.
-6. Format your code with [gofmt](https://golang.org/cmd/gofmt/).
-7. Make sure your code lints (`make lint`). Note: This requires Docker to run inside a local job.
diff --git a/.gitignore b/.gitignore
index fe774dfed..b9c95955f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,8 @@ docs/
out/
vendor/
.vscode
+.idea
+.DS_Store
# For supporting generated config of 2.1 syntax for local testing
.circleci/processed.yml
@@ -18,10 +20,6 @@ snap/.snapcraft/
stage/
*.snap
-# packr related
-*/*-packr.go
-packrd/
-
# golangci-lint
# We expect to install the binary each time in CI
bin/golangci-lint
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 000000000..0e2823522
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,36 @@
+---
+service:
+ golangci-lint-version: 1.46.x
+
+linters:
+ enable:
+ - deadcode
+ - errcheck
+ - goconst
+ - gofmt
+ - goimports
+ # - gosec
+ - gosimple
+ - govet
+ - ineffassign
+ - megacheck
+ - misspell
+ - nakedret
+ # - revive
+ - staticcheck
+ - structcheck
+ - typecheck
+ - unconvert
+ # - unparam
+ - unused
+ - varcheck
+ - vet
+ - vetshadow
+
+# Instead of disabling tests entirely, just ignore goconst, which is the only
+# one with issues there currently.
+issues:
+ exclude-rules:
+ - path: (.+)_test.go
+ linters:
+ - goconst
diff --git a/.gometalinter.json b/.gometalinter.json
deleted file mode 100644
index 77b13bda8..000000000
--- a/.gometalinter.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "Vendor": true,
- "Deadline": "5m",
- "Concurrency": 2,
- "Linters": {
- "gofmt": {"Command": "gofmt -l -s -w"},
- "goimports": {"Command": "goimports -l -w"}
- },
-
- "Exclude": [
- "md_docs/*"
- ],
-
- "Enable": [
- "deadcode",
- "errcheck",
- "goconst",
- "gocyclo",
- "gofmt",
- "goimports",
- "golint",
- "gosec",
- "gosimple",
- "gotype",
- "gotypex",
- "ineffassign",
- "interfacer",
- "megacheck",
- "misspell",
- "nakedret",
- "structcheck",
- "unconvert",
- "unparam",
- "varcheck",
- "vet",
- "vetshadow"
- ]
-}
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 6b82f0143..7c9dcbcab 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -10,10 +10,6 @@ archives:
- api/graphql/LICENSE
- md_docs/LICENSE
-before:
- hooks:
- - make pack
-
builds:
- binary: circleci
env:
@@ -25,6 +21,7 @@ builds:
- linux
goarch:
- amd64
+ - arm64
ldflags:
# -s Omit the symbol table and debug information.
# -w Omit the DWARF symbol table.
diff --git a/Dockerfile b/Dockerfile
index 1414f61c4..b5194eb1f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM circleci/golang:1.10.3
+FROM cimg/go:1.20
ENV CIRCLECI_CLI_SKIP_UPDATE_CHECK true
diff --git a/HACKING.md b/HACKING.md
index 6319cf2c2..717d4729a 100644
--- a/HACKING.md
+++ b/HACKING.md
@@ -24,7 +24,7 @@ You should already have [installed Go](https://golang.org/doc/install).
Clone the repo.
```
-$ git clone github.com/CircleCI-Public/circleci-cli
+$ git clone git@github.com:CircleCI-Public/circleci-cli.git
$ cd circleci-cli
```
diff --git a/Makefile b/Makefile
index f50603470..604d50148 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,6 @@ GOOS=$(shell go env GOOS)
GOARCH=$(shell go env GOARCH)
build: always
- GO111MODULE=on .circleci/pack.sh
go build -o build/$(GOOS)/$(GOARCH)/circleci
build-all: build/linux/amd64/circleci build/darwin/amd64/circleci
@@ -16,36 +15,22 @@ build/%/amd64/circleci: always
clean:
GO111MODULE=off go clean -i
rm -rf build out docs dist
- .circleci/pack.sh clean
.PHONY: test
test:
- go test -v ./...
+ TESTING=true go test -v ./...
.PHONY: cover
cover:
- go test -race -coverprofile=coverage.txt ./...
+ TESTING=true go test -race -coverprofile=coverage.txt ./...
.PHONY: lint
lint:
- bash .circleci/lint.sh
+ golangci-lint run
.PHONY: doc
doc:
godoc -http=:6060
-.PHONY: install-packr
-install-packr:
- bash .circleci/install-packr.sh
-
-.PHONY: pack
-pack:
- bash .circleci/pack.sh
-
-.PHONY: install-lint
-install-lint:
- bash .circleci/install-lint.sh
-
-
.PHONY: always
always:
diff --git a/README.md b/README.md
index f01bc0d73..ec03bea08 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,6 @@ This is CircleCI's command-line application.
[![CircleCI](https://circleci.com/gh/CircleCI-Public/circleci-cli.svg?style=shield)](https://circleci.com/gh/CircleCI-Public/circleci-cli)
[![GitHub release](https://img.shields.io/github/tag/CircleCI-Public/circleci-cli.svg?label=latest)](https://github.com/CircleCI-Public/circleci-cli/releases)
[![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)](https://godoc.org/github.com/CircleCI-Public/circleci-cli)
-[![Codecov](https://codecov.io/gh/CircleCI-Public/circleci-cli/branch/master/graph/badge.svg)](https://codecov.io/gh/CircleCI-Public/circleci-cli)
[![License](https://img.shields.io/badge/license-MIT-red.svg)](./LICENSE)
## Getting Started
@@ -42,27 +41,45 @@ choco install circleci-cli -y
You can also install the CLI binary by running our install script on most Unix platforms:
```
-curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/master/install.sh | bash
+curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/main/install.sh | bash
```
By default, the `circleci` app will be installed to the ``/usr/local/bin`` directory. If you do not have write permissions to `/usr/local/bin`, you may need to run the above command with `sudo`:
```
-curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/master/install.sh | sudo bash
+curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/main/install.sh | sudo bash
```
Alternatively, you can install to an alternate location by defining the `DESTDIR` environment variable when invoking `bash`:
```
-curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/master/install.sh | DESTDIR=/opt/bin bash
+curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/main/install.sh | DESTDIR=/opt/bin bash
```
You can also set a specific version of the CLI to install with the `VERSION` environment variable:
```
-curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/master/install.sh | VERSION=0.1.5222 sudo bash
+curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/main/install.sh | sudo VERSION=0.1.5222 bash
```
+Take note that additional environment variables should be passed between sudo and invoking bash.
+
+#### Checksum verification
+
+If you would like to verify the checksum yourself, you can download the checksum file from the [GitHub releases page](https://github.com/CircleCI-Public/circleci-cli/releases) and verify the checksum of the archive using the `circleci-cli__checksums.txt` inside the assets of the release you'd like to install:
+
+On macOS and Linux:
+```sh
+shasum -a 256 circleci-cli__.tar.gz
+```
+
+and on Windows:
+```powershell
+Get-FileHash .\circleci-cli__.tar.gz -Algorithm SHA256 | Format-List
+```
+
+And compare it to the right checksum depending on the downloaded version in the `circleci-cli__checksums.txt` file.
+
### Updating
If you installed the CLI without a package manager, you can use its built-in update command to check for pending updates and download them:
@@ -136,16 +153,17 @@ The following commands are affected:
## Platforms, Deployment and Package Managers
-The tool is deployed through a number of channels. The primary release channel is through [GitHub Releases](https://github.com/CircleCI-Public/circleci-cli/releases). Green builds on the `master` branch will publish a new GitHub release. These releases contain binaries for macOS, Linux and Windows. These releases are published from (CircleCI)[https://app.circleci.com/pipelines/github/CircleCI-Public/circleci-cli] using (GoReleaser)[https://goreleaser.com/].
+The tool is deployed through a number of channels. The primary release channel is through [GitHub Releases](https://github.com/CircleCI-Public/circleci-cli/releases). Green builds on the `main` branch will publish a new GitHub release. These releases contain binaries for macOS, Linux and Windows. These releases are published from (CircleCI)[https://app.circleci.com/pipelines/github/CircleCI-Public/circleci-cli] using (GoReleaser)[https://goreleaser.com/].
### Homebrew
-We publish the tool to [Homebrew](https://brew.sh/). The tool is [part of `homebrew-core`](https://github.com/Homebrew/homebrew-core/blob/master/Formula/circleci.rb), and therefore the maintainers of the tool are obligated to follow the guidelines for acceptable Homebrew formulae. You should [familairise yourself with the guidelines](https://docs.brew.sh/Acceptable-Formulae#we-dont-like-tools-that-upgrade-themselves) before making changes to the Homebrew deployment system.
+We publish the tool to [Homebrew](https://brew.sh/). The tool is [part of `homebrew-core`](https://github.com/Homebrew/homebrew-core/blob/main/Formula/circleci.rb), and therefore the maintainers of the tool are obligated to follow the guidelines for acceptable Homebrew formulae. You should [familairise yourself with the guidelines](https://docs.brew.sh/Acceptable-Formulae#we-dont-like-tools-that-upgrade-themselves) before making changes to the Homebrew deployment system.
The particular considerations that we make are:
+
1. Since Homebrew [doesn't "like tools that upgrade themselves"](https://docs.brew.sh/Acceptable-Formulae#we-dont-like-tools-that-upgrade-themselves), we disable the `circleci update` command when the tool is released through homebrew. We do this by [defining the PackageManager](https://github.com/Homebrew/homebrew-core/blob/eb1fdb84e2924289bcc8c85ee45081bf83dc024d/Formula/circleci.rb#L28) constant to `homebrew`, which allows us to [disable the `update` command at runtime](https://github.com/CircleCI-Public/circleci-cli/blob/67c7d52bace63846f87a1ed79f67f257c94a55b4/cmd/root.go#L119-L123).
-1. We want to avoid every push to `master` from creating a Pull Request to the `circleci` formula on Homebrew. We want to avoid overloading the Homebrew team with pull requests to update our formula for small changes (changes to docs or other files that don't change functionality in the tool).
+1. We want to avoid every push to `main` from creating a Pull Request to the `circleci` formula on Homebrew. We want to avoid overloading the Homebrew team with pull requests to update our formula for small changes (changes to docs or other files that don't change functionality in the tool).
### Snap
@@ -160,3 +178,4 @@ Development instructions for the CircleCI CLI can be found in [HACKING.md](HACKI
## More
Please see the [documentation](https://circleci-public.github.io/circleci-cli) or `circleci help` for more.
+
diff --git a/Taskfile.yml b/Taskfile.yml
new file mode 100644
index 000000000..ad2d19df9
--- /dev/null
+++ b/Taskfile.yml
@@ -0,0 +1,60 @@
+version: '3'
+
+tasks:
+ lint:
+ desc: Lint code
+ cmds:
+ - golangci-lint run -c .golangci.yml
+ summary: Lint the project with golangci-lint
+
+ clean:
+ desc: Cleans out the build, out, docs, and dist directories
+ cmds:
+ - GO111MODULE=off go clean -i
+ - rm -rf build out docs dist
+
+ fmt:
+ desc: Run `go fmt` to format the code
+ cmds:
+ - go fmt ./...
+
+ test:
+ desc: Run the tests
+ cmds:
+ - TESTING=true go test -v ./...
+
+ tidy:
+ desc: Run 'go mod tidy' to clean up module files.
+ cmds:
+ - go mod tidy -v
+
+ doc:
+ desc: run's the godocs
+ cmds:
+ - godoc -http=:6060
+
+ check-go-mod:
+ desc: Check go.mod is tidy
+ cmds:
+ - go mod tidy -v
+ - git diff --exit-code -- go.mod go.sum
+
+ vendor:
+ desc: go mod vendor
+ cmds:
+ - go mod vendor
+
+ build:
+ desc: Build main
+ cmds:
+ - go build -v -o build/darwin/amd64/circleci .
+
+ build-linux:
+ desc: Build main
+ cmds:
+ - go build -v -o build/linux/amd64/circleci .
+
+ cover:
+ desc: tests and generates a cover profile
+ cmds:
+ - TESTING=true go test -race -coverprofile=coverage.txt ./...
\ No newline at end of file
diff --git a/_data/data.yml b/_data/data.yml
deleted file mode 100644
index 425665b00..000000000
--- a/_data/data.yml
+++ /dev/null
@@ -1,9 +0,0 @@
-links:
- # cli_docs links to CLI documentation on circleci.com
- cli_docs: https://circleci.com/docs/2.0/local-cli/
-
- # orb_docs links to Orb documentation on circleci.com
- orb_docs: https://circleci.com/docs/2.0/orb-intro/
-
- # new_api_token links to the personal account API token page on circleci.com
- new_api_token: https://circleci.com/account/api
diff --git a/api/api.go b/api/api.go
index 2cd0f8cea..ac29d7c1f 100644
--- a/api/api.go
+++ b/api/api.go
@@ -3,7 +3,7 @@ package api
import (
"encoding/json"
"fmt"
- "io/ioutil"
+ "io"
"log"
"net/http"
"os"
@@ -11,7 +11,6 @@ import (
"strings"
"github.com/CircleCI-Public/circleci-cli/api/graphql"
- "github.com/CircleCI-Public/circleci-cli/pipeline"
"github.com/CircleCI-Public/circleci-cli/references"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/Masterminds/semver"
@@ -485,9 +484,9 @@ func loadYaml(path string) (string, error) {
var err error
var config []byte
if path == "-" {
- config, err = ioutil.ReadAll(os.Stdin)
+ config, err = io.ReadAll(os.Stdin)
} else {
- config, err = ioutil.ReadFile(path)
+ config, err = os.ReadFile(path)
}
if err != nil {
@@ -513,67 +512,6 @@ func WhoamiQuery(cl *graphql.Client) (*WhoamiResponse, error) {
return &response, nil
}
-// ConfigQuery calls the GQL API to validate and process config
-func ConfigQuery(cl *graphql.Client, configPath string, orgSlug string, params pipeline.Parameters, values pipeline.Values) (*ConfigResponse, error) {
- var response BuildConfigResponse
- var query string
-
- config, err := loadYaml(configPath)
- if err != nil {
- return nil, err
- }
-
- // GraphQL isn't forwards-compatible, so we are unusually selective here about
- // passing only non-empty fields on to the API, to minimize user impact if the
- // backend is out of date.
- var fieldAddendums string
- if orgSlug != "" {
- fieldAddendums += ", orgSlug: $orgSlug"
- }
- if len(params) > 0 {
- fieldAddendums += ", pipelineParametersJson: $pipelineParametersJson"
- }
- query = fmt.Sprintf(
- `query ValidateConfig ($config: String!, $pipelineParametersJson: String, $pipelineValues: [StringKeyVal!], $orgSlug: String) {
- buildConfig(configYaml: $config, pipelineValues: $pipelineValues%s) {
- valid,
- errors { message },
- sourceYaml,
- outputYaml
- }
- }`,
- fieldAddendums)
-
- request := graphql.NewRequest(query)
- request.Var("config", config)
- if values != nil {
- request.Var("pipelineValues", pipeline.PrepareForGraphQL(values))
- }
- if params != nil {
- pipelineParameters, err := json.Marshal(params)
- if err != nil {
- return nil, fmt.Errorf("unable to serialize pipeline values: %s", err.Error())
- }
- request.Var("pipelineParametersJson", string(pipelineParameters))
- }
- if orgSlug != "" {
- request.Var("orgSlug", orgSlug)
- }
- request.SetToken(cl.Token)
-
- err = cl.Run(request, &response)
-
- if err != nil {
- return nil, errors.Wrap(err, "Unable to validate config")
- }
-
- if len(response.BuildConfig.ConfigResponse.Errors) > 0 {
- return nil, &response.BuildConfig.ConfigResponse.Errors
- }
-
- return &response.BuildConfig.ConfigResponse, nil
-}
-
// OrbQuery validated and processes an orb.
func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) {
var response OrbConfigResponse
@@ -802,7 +740,7 @@ func CreateImportedNamespace(cl *graphql.Client, name string) (*ImportNamespaceR
return &response, nil
}
-func createNamespaceWithOwnerID(cl *graphql.Client, name string, ownerID string) (*CreateNamespaceResponse, error) {
+func CreateNamespaceWithOwnerID(cl *graphql.Client, name string, ownerID string) (*CreateNamespaceResponse, error) {
var response CreateNamespaceResponse
query := `
@@ -959,7 +897,7 @@ func CreateNamespace(cl *graphql.Client, name string, organizationName string, o
return nil, errors.Wrap(organizationNotFound(organizationName, organizationVcs), getOrgError.Error())
}
- createNSResponse, createNSError := createNamespaceWithOwnerID(cl, name, getOrgResponse.Organization.ID)
+ createNSResponse, createNSError := CreateNamespaceWithOwnerID(cl, name, getOrgResponse.Organization.ID)
if createNSError != nil {
return nil, createNSError
@@ -1364,6 +1302,7 @@ func OrbSource(cl *graphql.Client, orbRef string) (string, error) {
}`
request := graphql.NewRequest(query)
+ request.SetToken(cl.Token)
request.Var("orbVersionRef", ref)
err := cl.Run(request, &response)
@@ -1575,6 +1514,7 @@ query namespaceOrbs ($namespace: String, $after: String!) {
for {
request := graphql.NewRequest(query)
+ request.SetToken(cl.Token)
request.Var("after", currentCursor)
request.Var("namespace", namespace)
@@ -1614,7 +1554,7 @@ query namespaceOrbs ($namespace: String, $after: String!) {
// ListNamespaceOrbs queries the API to find all orbs belonging to the given
// namespace.
// Returns a collection of Orb objects containing their relevant data.
-func ListNamespaceOrbs(cl *graphql.Client, namespace string, isPrivate bool) (*OrbsForListing, error) {
+func ListNamespaceOrbs(cl *graphql.Client, namespace string, isPrivate, showDetails bool) (*OrbsForListing, error) {
l := log.New(os.Stderr, "", 0)
query := `
@@ -1626,9 +1566,15 @@ query namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType
edges {
cursor
node {
- versions {
- source
- version
+ versions `
+
+ if showDetails {
+ query += `(count: 1){ source,`
+ } else {
+ query += `{`
+ }
+
+ query += ` version
}
name
statistics {
@@ -1646,6 +1592,7 @@ query namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType
}
}
`
+
var orbs OrbsForListing
var result NamespaceOrbResponse
currentCursor := ""
@@ -1747,7 +1694,7 @@ func OrbCategoryID(cl *graphql.Client, name string) (*OrbCategoryIDResponse, err
}`
request := graphql.NewRequest(query)
-
+ request.SetToken(cl.Token)
request.Var("name", name)
err := cl.Run(request, &response)
@@ -1851,6 +1798,7 @@ func ListOrbCategories(cl *graphql.Client) (*OrbCategoriesForListing, error) {
for {
request := graphql.NewRequest(query)
+ request.SetToken(cl.Token)
request.Var("after", currentCursor)
err := cl.Run(request, &result)
@@ -1874,25 +1822,34 @@ func ListOrbCategories(cl *graphql.Client) (*OrbCategoriesForListing, error) {
// FollowProject initiates an API request to follow a specific project on
// CircleCI. Project slugs are case-sensitive.
+
+var errorMessage = `Unable to follow project`
+
func FollowProject(config settings.Config, vcs string, owner string, projectName string) (FollowedProject, error) {
+
requestPath := fmt.Sprintf("%s/api/v1.1/project/%s/%s/%s/follow", config.Host, vcs, owner, projectName)
r, err := http.NewRequest(http.MethodPost, requestPath, nil)
if err != nil {
- return FollowedProject{}, err
+ return FollowedProject{}, errors.Wrap(err, errorMessage)
}
r.Header.Set("Content-Type", "application/json; charset=utf-8")
r.Header.Set("Accept", "application/json; charset=utf-8")
- r.Header.Set("Circle-Token", config.Token)
+ if config.Token != "" {
+ r.Header.Set("Circle-Token", config.Token)
+ }
response, err := config.HTTPClient.Do(r)
if err != nil {
return FollowedProject{}, err
}
+ if response.StatusCode >= 400 {
+ return FollowedProject{}, errors.New("Could not follow project")
+ }
var fr FollowedProject
err = json.NewDecoder(response.Body).Decode(&fr)
if err != nil {
- return FollowedProject{}, err
+ return FollowedProject{}, errors.Wrap(err, errorMessage)
}
return fr, nil
diff --git a/api/api_test.go b/api/api_test.go
index ddd32bac8..1d32f7d39 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -61,7 +61,7 @@ func TestFollowProject(t *testing.T) {
expErr: "invalid character '/'",
},
{
- label: "returns a followed project succesfully",
+ label: "returns a followed project successfully",
transportFn: func(r *http.Request) (*http.Response, error) {
if r.URL.String() != "https://circleci.com/api/v1.1/project/github/test-user/test-project/follow" {
panic(fmt.Sprintf("unexpected url: %s", r.URL.String()))
diff --git a/api/context.go b/api/context.go
index c97572186..3921e54cc 100644
--- a/api/context.go
+++ b/api/context.go
@@ -7,16 +7,16 @@ import (
// An EnvironmentVariable has a Variable, a ContextID (its owner), and a
// CreatedAt date.
type EnvironmentVariable struct {
- Variable string
+ Variable string
ContextID string
CreatedAt time.Time
}
// A Context is the owner of EnvironmentVariables.
-type Context struct{
+type Context struct {
CreatedAt time.Time `json:"created_at"`
- ID string `json:"id"`
- Name string `json:"name"`
+ ID string `json:"id"`
+ Name string `json:"name"`
}
// ContextInterface is the interface to interact with contexts and environment
@@ -25,8 +25,8 @@ type ContextInterface interface {
Contexts(vcs, org string) (*[]Context, error)
ContextByName(vcs, org, name string) (*Context, error)
DeleteContext(contextID string) error
- CreateContext(vcs, org, name string) (error)
-
+ CreateContext(vcs, org, name string) error
+ CreateContextWithOrgID(orgID *string, name string) error
EnvironmentVariables(contextID string) (*[]EnvironmentVariable, error)
CreateEnvironmentVariable(contextID, variable, value string) error
DeleteEnvironmentVariable(contextID, variable string) error
diff --git a/api/context_graphql.go b/api/context_graphql.go
index 1b6a07aa8..b1d2a2c02 100644
--- a/api/context_graphql.go
+++ b/api/context_graphql.go
@@ -52,11 +52,22 @@ func (c *GraphQLContextClient) CreateContext(vcsType, orgName, contextName strin
cl := c.Client
org, err := getOrganization(cl, orgName, vcsType)
+ if err != nil {
+ return err
+ }
+ err = c.CreateContextWithOrgID(&org.Organization.ID, contextName)
if err != nil {
return err
}
+ return nil
+}
+
+// CreateContextWithOrgID creates a new Context in the supplied organization.
+func (c *GraphQLContextClient) CreateContextWithOrgID(orgID *string, contextName string) error {
+ cl := c.Client
+
query := `
mutation CreateContext($input: CreateContextInput!) {
createContext(input: $input) {
@@ -78,7 +89,7 @@ func (c *GraphQLContextClient) CreateContext(vcsType, orgName, contextName strin
ContextName string `json:"contextName"`
}
- input.OwnerId = org.Organization.ID
+ input.OwnerId = *orgID
input.OwnerType = "ORGANIZATION"
input.ContextName = contextName
@@ -94,14 +105,13 @@ func (c *GraphQLContextClient) CreateContext(vcsType, orgName, contextName strin
}
}
- if err = cl.Run(request, &response); err != nil {
+ if err := cl.Run(request, &response); err != nil {
return improveVcsTypeError(err)
}
if response.CreateContext.Error.Type != "" {
return fmt.Errorf("Error creating context: %s", response.CreateContext.Error.Type)
}
-
return nil
}
@@ -275,7 +285,7 @@ func (c *GraphQLContextClient) DeleteEnvironmentVariable(contextId, variableName
}
err := cl.Run(request, &response)
- return errors.Wrap(improveVcsTypeError(err), "failed to delete environment varaible")
+ return errors.Wrap(improveVcsTypeError(err), "failed to delete environment variable")
}
// CreateEnvironmentVariable creates a new environment variable in the given
@@ -333,7 +343,7 @@ func (c *GraphQLContextClient) CreateEnvironmentVariable(contextId, variableName
}
if err := cl.Run(request, &response); err != nil {
- return errors.Wrap(improveVcsTypeError(err), "failed to store environment varaible in context")
+ return errors.Wrap(improveVcsTypeError(err), "failed to store environment variable in context")
}
if response.StoreEnvironmentVariable.Error.Type != "" {
diff --git a/api/context_rest.go b/api/context_rest.go
index afae72d5e..17b2ff21d 100644
--- a/api/context_rest.go
+++ b/api/context_rest.go
@@ -5,10 +5,8 @@ import (
"encoding/json"
"fmt"
"io"
- "io/ioutil"
"net/http"
"net/url"
- "strings"
"github.com/CircleCI-Public/circleci-cli/api/header"
"github.com/CircleCI-Public/circleci-cli/settings"
@@ -72,7 +70,7 @@ func (c *ContextRestClient) DeleteEnvironmentVariable(contextID, variable string
return err
}
- bodyBytes, err := ioutil.ReadAll(resp.Body)
+ bodyBytes, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return err
@@ -88,6 +86,37 @@ func (c *ContextRestClient) DeleteEnvironmentVariable(contextID, variable string
return nil
}
+func (c *ContextRestClient) CreateContextWithOrgID(orgID *string, name string) error {
+ req, err := c.newCreateContextRequestWithOrgID(orgID, name)
+ if err != nil {
+ return err
+ }
+
+ resp, err := c.client.Do(req)
+
+ if err != nil {
+ return err
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ defer resp.Body.Close()
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != 200 {
+ var dest errorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return err
+ }
+ return errors.New(*dest.Message)
+ }
+ var dest Context
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return err
+ }
+ return nil
+}
+
// CreateContext creates a new context in the supplied organization.
func (c *ContextRestClient) CreateContext(vcs, org, name string) error {
req, err := c.newCreateContextRequest(vcs, org, name)
@@ -101,7 +130,7 @@ func (c *ContextRestClient) CreateContext(vcs, org, name string) error {
return err
}
- bodyBytes, err := ioutil.ReadAll(resp.Body)
+ bodyBytes, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return err
@@ -132,7 +161,7 @@ func (c *ContextRestClient) CreateEnvironmentVariable(contextID, variable, value
return err
}
- bodyBytes, err := ioutil.ReadAll(resp.Body)
+ bodyBytes, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return err
@@ -160,7 +189,7 @@ func (c *ContextRestClient) DeleteContext(contextID string) error {
return err
}
- bodyBytes, err := ioutil.ReadAll(resp.Body)
+ bodyBytes, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return err
@@ -270,7 +299,7 @@ func (c *ContextRestClient) listEnvironmentVariables(params *listEnvironmentVari
return nil, err
}
- bodyBytes, err := ioutil.ReadAll(resp.Body)
+ bodyBytes, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return nil, err
@@ -304,7 +333,7 @@ func (c *ContextRestClient) listContexts(params *listContextsParams) (*listConte
return nil, err
}
- bodyBytes, err := ioutil.ReadAll(resp.Body)
+ bodyBytes, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return nil, err
@@ -329,6 +358,7 @@ func (c *ContextRestClient) listContexts(params *listContextsParams) (*listConte
return &dest, nil
}
+// newCreateContextRequest posts a new context creation with orgname and vcs type using a slug
func (c *ContextRestClient) newCreateContextRequest(vcs, org, name string) (*http.Request, error) {
var err error
queryURL, err := url.Parse(c.server)
@@ -352,6 +382,7 @@ func (c *ContextRestClient) newCreateContextRequest(vcs, org, name string) (*htt
Owner: struct {
Slug *string `json:"slug,omitempty"`
}{
+
Slug: toSlug(vcs, org),
},
}
@@ -366,6 +397,45 @@ func (c *ContextRestClient) newCreateContextRequest(vcs, org, name string) (*htt
return c.newHTTPRequest("POST", queryURL.String(), bodyReader)
}
+// newCreateContextRequestWithOrgID posts a new context creation with an orgID
+func (c *ContextRestClient) newCreateContextRequestWithOrgID(orgID *string, name string) (*http.Request, error) {
+ var err error
+ queryURL, err := url.Parse(c.server)
+ if err != nil {
+ return nil, err
+ }
+ queryURL, err = queryURL.Parse("context")
+ if err != nil {
+ return nil, err
+ }
+
+ var bodyReader io.Reader
+
+ var body = struct {
+ Name string `json:"name"`
+ Owner struct {
+ ID *string `json:"id,omitempty"`
+ } `json:"owner"`
+ }{
+ Name: name,
+ Owner: struct {
+ ID *string `json:"id,omitempty"`
+ }{
+
+ ID: orgID,
+ },
+ }
+ buf, err := json.Marshal(body)
+
+ if err != nil {
+ return nil, err
+ }
+
+ bodyReader = bytes.NewReader(buf)
+
+ return c.newHTTPRequest("POST", queryURL.String(), bodyReader)
+}
+
func (c *ContextRestClient) newCreateEnvironmentVariableRequest(contextID, variable, value string) (*http.Request, error) {
var err error
queryURL, err := url.Parse(c.server)
@@ -474,7 +544,9 @@ func (c *ContextRestClient) newHTTPRequest(method, url string, body io.Reader) (
if err != nil {
return nil, err
}
- req.Header.Add("circle-token", c.token)
+ if c.token != "" {
+ req.Header.Add("circle-token", c.token)
+ }
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json")
req.Header.Add("User-Agent", version.UserAgent())
@@ -509,7 +581,7 @@ func (c *ContextRestClient) EnsureExists() error {
return errors.New("API v2 test request failed.")
}
- bodyBytes, err := ioutil.ReadAll(resp.Body)
+ bodyBytes, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return err
@@ -533,16 +605,7 @@ func (c *ContextRestClient) EnsureExists() error {
// NewContextRestClient returns a new client satisfying the api.ContextInterface
// interface via the REST API.
func NewContextRestClient(config settings.Config) (*ContextRestClient, error) {
- // Ensure server ends with a slash
- if !strings.HasSuffix(config.RestEndpoint, "/") {
- config.RestEndpoint += "/"
- }
- serverURL, err := url.Parse(config.Host)
- if err != nil {
- return nil, err
- }
-
- serverURL, err = serverURL.Parse(config.RestEndpoint)
+ serverURL, err := config.ServerURL()
if err != nil {
return nil, err
}
diff --git a/api/context_rest_test.go b/api/context_rest_test.go
new file mode 100644
index 000000000..005c64908
--- /dev/null
+++ b/api/context_rest_test.go
@@ -0,0 +1,98 @@
+package api
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ "github.com/onsi/gomega/ghttp"
+)
+
+type MockRequestResponse struct {
+ Request string
+ Status int
+ Response string
+ ErrorResponse string
+}
+
+// Uses Ginkgo http handler to mock out http requests and make assertions off the results.
+// If ErrorResponse is defined in the passed handler it will override the Response.
+func appendRESTPostHandler(server *ghttp.Server, combineHandlers ...MockRequestResponse) {
+ for _, handler := range combineHandlers {
+ responseBody := handler.Response
+ if handler.ErrorResponse != "" {
+ responseBody = handler.ErrorResponse
+ }
+
+ server.AppendHandlers(
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", "/api/v2/context"),
+ ghttp.VerifyContentType("application/json"),
+ func(w http.ResponseWriter, req *http.Request) {
+ body, err := io.ReadAll(req.Body)
+ Expect(err).ShouldNot(HaveOccurred())
+ err = req.Body.Close()
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(handler.Request).Should(MatchJSON(body), "JSON Mismatch")
+ },
+ ghttp.RespondWith(handler.Status, responseBody),
+ ),
+ )
+ }
+}
+
+func getContextRestClient(server *ghttp.Server) (*ContextRestClient, error) {
+ client := &http.Client{}
+
+ return NewContextRestClient(settings.Config{
+ RestEndpoint: "api/v2",
+ Host: server.URL(),
+ HTTPClient: client,
+ Token: "token",
+ })
+}
+
+var _ = ginkgo.Describe("Context Rest Tests", func() {
+ ginkgo.It("Should handle a successful request with createContextWithOrgID", func() {
+ server := ghttp.NewServer()
+
+ defer server.Close()
+
+ name := "name"
+ orgID := "497f6eca-6276-4993-bfeb-53cbbbba6f08"
+ client, err := getContextRestClient(server)
+ Expect(err).To(BeNil())
+
+ appendRESTPostHandler(server, MockRequestResponse{
+ Status: http.StatusOK,
+ Request: fmt.Sprintf(`{"name": "%s","owner":{"id":"%s"}}`, name, orgID),
+ Response: fmt.Sprintf(`{"id": "%s", "name": "%s", "created_at": "2015-09-21T17:29:21.042Z" }`, orgID, name),
+ })
+
+ err = client.CreateContextWithOrgID(&orgID, name)
+ Expect(err).To(BeNil())
+ })
+
+ ginkgo.It("Should handle an error request with createContextWithOrgID", func() {
+ server := ghttp.NewServer()
+
+ defer server.Close()
+
+ name := "name"
+ orgID := "497f6eca-6276-4993-bfeb-53cbbbba6f08"
+ client, err := getContextRestClient(server)
+ Expect(err).To(BeNil())
+
+ appendRESTPostHandler(server, MockRequestResponse{
+ Status: http.StatusInternalServerError,
+ Request: fmt.Sprintf(`{"name": "%s","owner":{"id":"%s"}}`, name, orgID),
+ ErrorResponse: `{"message": "🍎"}`,
+ })
+
+ err = client.CreateContextWithOrgID(&orgID, name)
+ Expect(err).ToNot(BeNil())
+ })
+})
diff --git a/api/context_test.go b/api/context_test.go
index 607acdfe3..fb6d188dd 100644
--- a/api/context_test.go
+++ b/api/context_test.go
@@ -42,6 +42,8 @@ func createSingleUseGraphQLServer(result interface{}, requestAssertions func(req
}
var _ = ginkgo.Describe("API", func() {
+ orgID := "bb604b45-b6b0-4b81-ad80-796f15eddf87"
+
ginkgo.Describe("FooBar", func() {
ginkgo.It("improveVcsTypeError", func() {
@@ -94,6 +96,30 @@ var _ = ginkgo.Describe("API", func() {
})
+ ginkgo.It("can handles failure creating contexts", func() {
+
+ var result struct {
+ CreateContext struct {
+ Error struct {
+ Type string
+ }
+ }
+ }
+
+ result.CreateContext.Error.Type = "force-this-error"
+
+ server, client := createSingleUseGraphQLServer(result, func(count uint64, req *graphQLRequest) {
+ switch count {
+ case 1:
+ Expect(req.Variables["input"].(map[string]interface{})["ownerId"]).To(Equal(orgID))
+ }
+ })
+ defer server.Close()
+ err := client.CreateContextWithOrgID(&orgID, "foo-bar")
+ Expect(err).To(MatchError("Error creating context: force-this-error"))
+
+ })
+
})
ginkgo.It("can handles success creating contexts", func() {
@@ -126,6 +152,29 @@ var _ = ginkgo.Describe("API", func() {
})
+ ginkgo.It("can handles success creating contexts with create context with orgID", func() {
+
+ var result struct {
+ CreateContext struct {
+ Error struct {
+ Type string
+ }
+ }
+ }
+
+ result.CreateContext.Error.Type = ""
+
+ server, client := createSingleUseGraphQLServer(result, func(count uint64, req *graphQLRequest) {
+ switch count {
+ case 1:
+ Expect(req.Variables["input"].(map[string]interface{})["ownerId"]).To(Equal(orgID))
+ }
+ })
+ defer server.Close()
+ Expect(client.CreateContextWithOrgID(&orgID, "foo-bar")).To(Succeed())
+
+ })
+
ginkgo.Describe("List Contexts", func() {
ginkgo.It("can list contexts", func() {
@@ -139,7 +188,7 @@ var _ = ginkgo.Describe("API", func() {
list.Organization.Id = "C3D79A95-6BD5-40B4-9958-AB6BDC4CAD50"
list.Organization.Contexts.Edges = []struct{ Node circleCIContext }{
- struct{ Node circleCIContext }{
+ {
Node: ctx,
},
}
diff --git a/api/dl/dl.go b/api/dl/dl.go
new file mode 100644
index 000000000..148e37763
--- /dev/null
+++ b/api/dl/dl.go
@@ -0,0 +1,6 @@
+package dl
+
+// ProjectClient is the interface to interact with dl
+type DlClient interface {
+ PurgeDLC(projectid string) error
+}
diff --git a/api/dl/dl_rest.go b/api/dl/dl_rest.go
new file mode 100644
index 000000000..92db226f1
--- /dev/null
+++ b/api/dl/dl_rest.go
@@ -0,0 +1,80 @@
+package dl
+
+import (
+ "fmt"
+ "net/url"
+ "time"
+
+ "github.com/CircleCI-Public/circleci-cli/api/rest"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+)
+
+const defaultDlHost = "https://dl.circleci.com"
+
+type dlRestClient struct {
+ client *rest.Client
+}
+
+// NewDlRestClient returns a new dlRestClient instance initialized with the
+// values from the config.
+func NewDlRestClient(config settings.Config) (*dlRestClient, error) { //
+ // We don't want the user to use this with Server as that's nor supported
+ // at them moment. In order to detect this we look if there's a config file
+ // or cli option that sets "host" to anything different than the default
+ if config.Host != "" && config.Host != "https://circleci.com" {
+ // Only error if there's no custom DlHost set. Since the end user can't
+ // a custom value set this in the config file, this has to have been
+ // manually been set in the code, presumably by the test suite to allow
+ // talking to a mock server, and we want to allow that.
+ if config.DlHost == "" {
+ return nil, &CloudOnlyErr{}
+ }
+ }
+
+ // what's the base URL?
+ unparsedURL := defaultDlHost
+ if config.DlHost != "" {
+ unparsedURL = config.DlHost
+ }
+
+ baseURL, err := url.Parse(unparsedURL)
+ if err != nil {
+ return nil, fmt.Errorf("cannot parse dl host URL '%s'", unparsedURL)
+ }
+
+ httpclient := config.HTTPClient
+ httpclient.Timeout = 10 * time.Second
+
+ // the dl endpoint is hardcoded to https://dl.circleci.com, since currently
+ // this implementation always refers to the cloud dl service
+ return &dlRestClient{
+ client: rest.New(
+ baseURL,
+ config.Token,
+ httpclient,
+ ),
+ }, nil
+}
+
+func (c dlRestClient) PurgeDLC(projectid string) error {
+ // this calls a private circleci endpoint. We make no guarantees about
+ // this still existing in the future.
+ path := fmt.Sprintf("private/output/project/%s/dlc", projectid)
+ req, err := c.client.NewRequest("DELETE", &url.URL{Path: path}, nil)
+ if err != nil {
+ return err
+ }
+
+ status, err := c.client.DoRequest(req, nil)
+
+ // Futureproofing: If CircleCI ever removes the private backend endpoint
+ // this call uses, by having the endpoint return a 410 status code CircleCI
+ // can get everyone running an outdated client to display a helpful error
+ // telling them to upgrade (presumably by this point a version without this
+ // logic will have been released)
+ if status == 410 {
+ return &GoneErr{}
+ }
+
+ return err
+}
diff --git a/api/dl/dl_rest_test.go b/api/dl/dl_rest_test.go
new file mode 100644
index 000000000..7262b46ab
--- /dev/null
+++ b/api/dl/dl_rest_test.go
@@ -0,0 +1,81 @@
+package dl
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "gotest.tools/v3/assert"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/CircleCI-Public/circleci-cli/version"
+)
+
+// getDlRestClient returns a dlRestClient hooked up to the passed server
+func getDlRestClient(server *httptest.Server) (*dlRestClient, error) {
+ return NewDlRestClient(settings.Config{
+ DlHost: server.URL,
+ HTTPClient: http.DefaultClient,
+ Token: "token",
+ })
+}
+
+func Test_DLCPurge(t *testing.T) {
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ wantErr bool
+ }{
+ {
+ name: "Should handle a successful request",
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "token")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+
+ assert.Equal(t, r.Method, "DELETE")
+ assert.Equal(t, r.URL.Path, fmt.Sprintf("/private/output/project/%s/dlc", "projectid"))
+
+ // check the request was made with an empty body
+ br := r.Body
+ b, err := io.ReadAll(br)
+ assert.NilError(t, err)
+ assert.Equal(t, string(b), "")
+ assert.NilError(t, br.Close())
+
+ // send response as empty 200
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, err = w.Write([]byte(``))
+ assert.NilError(t, err)
+ },
+ },
+ {
+ name: "Should handle an error request",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("content-type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, err := w.Write([]byte(`{"message": "error"}`))
+ assert.NilError(t, err)
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(tt.handler)
+ defer server.Close()
+
+ c, err := getDlRestClient(server)
+ assert.NilError(t, err)
+
+ err = c.PurgeDLC("projectid")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("PurgeDLC() error = %#v (%s), wantErr %v", err, err, tt.wantErr)
+ return
+ }
+ })
+ }
+}
diff --git a/api/dl/err.go b/api/dl/err.go
new file mode 100644
index 000000000..e75ffc5b3
--- /dev/null
+++ b/api/dl/err.go
@@ -0,0 +1,27 @@
+package dl
+
+type CloudOnlyErr struct{}
+
+func (e *CloudOnlyErr) Error() string {
+ return "Misconfiguration.\n" +
+ "You have configured a custom API endpoint host for the circleci CLI.\n" +
+ "However, this functionality is only supported on circleci.com API endpoints."
+}
+
+func IsCloudOnlyErr(err error) bool {
+ _, ok := err.(*CloudOnlyErr)
+ return ok
+}
+
+type GoneErr struct{}
+
+func (e *GoneErr) Error() string {
+ return "No longer supported.\n" +
+ "This functionality is no longer supported by this version of the circleci CLI.\n" +
+ "Please upgrade to the latest version of the circleci CLI."
+}
+
+func IsGoneErr(err error) bool {
+ _, ok := err.(*GoneErr)
+ return ok
+}
diff --git a/api/graphql/client.go b/api/graphql/client.go
index e5593ad84..d5c5d8613 100644
--- a/api/graphql/client.go
+++ b/api/graphql/client.go
@@ -5,7 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
- "io/ioutil"
+ "io"
"log"
"net/http"
"net/url"
@@ -29,7 +29,7 @@ type Client struct {
// NewClient returns a reference to a Client.
func NewClient(httpClient *http.Client, host, endpoint, token string, debug bool) *Client {
return &Client{
- httpClient: http.DefaultClient,
+ httpClient: httpClient,
Endpoint: endpoint,
Host: host,
Token: token,
@@ -254,7 +254,7 @@ func (cl *Client) Run(request *Request, resp interface{}) error {
if cl.Debug {
var bodyBytes []byte
if res.Body != nil {
- bodyBytes, err = ioutil.ReadAll(res.Body)
+ bodyBytes, err = io.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "reading response")
}
@@ -262,7 +262,7 @@ func (cl *Client) Run(request *Request, resp interface{}) error {
l.Printf("<< %s", string(bodyBytes))
// Restore the io.ReadCloser to its original state
- res.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
+ res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
diff --git a/api/graphql/client_test.go b/api/graphql/client_test.go
index b0a27d471..2edf3ef9c 100644
--- a/api/graphql/client_test.go
+++ b/api/graphql/client_test.go
@@ -2,7 +2,6 @@ package graphql
import (
"io"
- "io/ioutil"
"net/http"
"net/http/httptest"
"regexp"
@@ -53,7 +52,7 @@ func TestDoJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
- b, err := ioutil.ReadAll(r.Body)
+ b, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf(err.Error())
}
@@ -96,7 +95,7 @@ func TestQueryJSON(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
- b, err := ioutil.ReadAll(r.Body)
+ b, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf(err.Error())
}
@@ -146,7 +145,7 @@ func TestDoJSONErr(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) {
calls++
- body, err := ioutil.ReadAll(req.Body)
+ body, err := io.ReadAll(req.Body)
if err != nil {
t.Errorf(err.Error())
}
diff --git a/api/info/info.go b/api/info/info.go
new file mode 100644
index 000000000..d8feb74e4
--- /dev/null
+++ b/api/info/info.go
@@ -0,0 +1,116 @@
+package info
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "github.com/CircleCI-Public/circleci-cli/api/header"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/CircleCI-Public/circleci-cli/version"
+)
+
+// InfoClient An interface with all the Info Functions.
+type InfoClient interface {
+ GetInfo() (*[]Organization, error)
+}
+
+// errorResponse used to handle error messages from the API.
+type errorResponse struct {
+ Message *string `json:"message"`
+}
+
+// InfoRESTClient A restful implementation of the InfoClient
+type InfoRESTClient struct {
+ token string
+ server string
+ client *http.Client
+}
+
+// organization json org info
+type Organization struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// GetInfo
+func (c *InfoRESTClient) GetInfo() (*[]Organization, error) {
+ var err error
+ queryURL, err := url.Parse(c.server)
+ if err != nil {
+ return nil, err
+ }
+ queryURL, err = queryURL.Parse("me/collaborations")
+ if err != nil {
+ return nil, err
+ }
+ req, err := c.newHTTPRequest("GET", queryURL.String(), nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct new request: %v", err)
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ defer resp.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != 200 {
+ var dest errorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+
+ }
+ return nil, errors.New(*dest.Message)
+ }
+
+ orgs := make([]Organization, 0)
+ if err := json.Unmarshal(bodyBytes, &orgs); err != nil {
+ return nil, err
+ }
+
+ return &orgs, nil
+}
+
+// newHTTPRequest Creates a new standard HTTP request object used to communicate with the API
+func (c *InfoRESTClient) newHTTPRequest(method, url string, body io.Reader) (*http.Request, error) {
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return nil, err
+ }
+ if c.token != "" {
+ req.Header.Add("circle-token", c.token)
+ }
+ req.Header.Add("Accept", "application/json")
+ req.Header.Add("Content-Type", "application/json")
+ req.Header.Add("User-Agent", version.UserAgent())
+ commandStr := header.GetCommandStr()
+ if commandStr != "" {
+ req.Header.Add("Circleci-Cli-Command", commandStr)
+ }
+ return req, nil
+}
+
+// Creates a new client to talk with the rest info endpoints.
+func NewInfoClient(config settings.Config) (InfoClient, error) {
+ serverURL, err := config.ServerURL()
+ if err != nil {
+ return nil, err
+ }
+
+ client := &InfoRESTClient{
+ token: config.Token,
+ server: serverURL.String(),
+ client: config.HTTPClient,
+ }
+
+ return client, nil
+}
diff --git a/api/info/info_test.go b/api/info/info_test.go
new file mode 100644
index 000000000..66c604e1c
--- /dev/null
+++ b/api/info/info_test.go
@@ -0,0 +1,78 @@
+package info
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "gotest.tools/v3/assert"
+)
+
+func TestOkResponse(t *testing.T) {
+ token := "pluto-is-a-planet"
+ id := "id"
+ name := "name"
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/me/collaborations")
+ assert.Equal(t, r.Header.Get("circle-token"), token)
+ assert.Equal(t, r.Header.Get("Content-Type"), "application/json")
+ assert.Equal(t, r.Header.Get("Accept"), "application/json")
+
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(fmt.Sprintf(`[{"id": "%s", "name": "%s"}]`, id, name)))
+ assert.NilError(t, err)
+ }))
+
+ defer server.Close()
+
+ config := settings.Config{
+ Host: server.URL,
+ HTTPClient: http.DefaultClient,
+ Token: token,
+ }
+
+ client, _ := NewInfoClient(config)
+ orgs, err := client.GetInfo()
+ organizations := *orgs
+
+ assert.NilError(t, err)
+ assert.Equal(t, len(organizations), 1)
+
+ org := organizations[0]
+ assert.Equal(t, org.ID, id)
+ assert.Equal(t, org.Name, name)
+}
+
+func TestServerErrorResponse(t *testing.T) {
+ token := "pluto-is-a-planet"
+ message := "i-come-in-peace"
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/me/collaborations")
+ assert.Equal(t, r.Header.Get("circle-token"), token)
+ assert.Equal(t, r.Header.Get("Content-Type"), "application/json")
+ assert.Equal(t, r.Header.Get("Accept"), "application/json")
+
+ w.WriteHeader(http.StatusInternalServerError)
+ _, err := w.Write([]byte(fmt.Sprintf(`{"message": "%s"}`, message)))
+ assert.NilError(t, err)
+ }))
+
+ defer server.Close()
+
+ config := settings.Config{
+ Host: server.URL,
+ HTTPClient: http.DefaultClient,
+ Token: token,
+ }
+
+ client, _ := NewInfoClient(config)
+ _, err := client.GetInfo()
+
+ assert.Error(t, err, message)
+}
diff --git a/api/policy/policy.go b/api/policy/policy.go
new file mode 100644
index 000000000..19babab9a
--- /dev/null
+++ b/api/policy/policy.go
@@ -0,0 +1,368 @@
+package policy
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/CircleCI-Public/circle-policy-agent/cpa"
+
+ "github.com/CircleCI-Public/circleci-cli/api/header"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/CircleCI-Public/circleci-cli/version"
+)
+
+// Policy-service endpoints documentation : https://github.com/circleci/policy-service/blob/main/cmd/server/openapi.yaml
+
+// Client communicates with the CircleCI policy-service to ask questions
+// about policies. It satisfies policy.ClientInterface.
+type Client struct {
+ serverUrl string
+ client *http.Client
+}
+
+// httpError represents error response json payload as sent by the policy-server: internal/error.go
+type httpError struct {
+ Error string `json:"error"`
+ Context map[string]interface{} `json:"context,omitempty"`
+}
+
+// Creation types taken from policy-service: internal/policy/api.go
+
+// CreatePolicyBundleRequest defines the fields for the Create-Policy-Bundle endpoint as defined in Policy Service
+type CreatePolicyBundleRequest struct {
+ Policies map[string]string `json:"policies"`
+ DryRun bool `json:"-"`
+}
+
+// CreatePolicyBundle calls the Create Policy Bundle API in the Policy-Service.
+// It creates a policy bundle for the specified owner+context and returns the http status code as response
+func (c Client) CreatePolicyBundle(ownerID string, context string, request CreatePolicyBundleRequest) (interface{}, error) {
+ data, err := json.Marshal(request)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode policy payload: %w", err)
+ }
+
+ req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/owner/%s/context/%s/policy-bundle", c.serverUrl, ownerID, context), bytes.NewReader(data))
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct request: %v", err)
+ }
+
+ req.Header.Set("Content-Length", strconv.Itoa(len(data)))
+
+ if request.DryRun {
+ q := req.URL.Query()
+ q.Set("dry", "true")
+ req.URL.RawQuery = q.Encode()
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get response from policy-service: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
+ var response httpError
+ if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+ return nil, fmt.Errorf("unexpected status-code: %d", resp.StatusCode)
+ }
+ return nil, fmt.Errorf("unexpected status-code: %d - %s", resp.StatusCode, response.Error)
+ }
+
+ var body interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("failed to decode response body: %v", err)
+ }
+
+ return body, nil
+}
+
+// FetchPolicyBundle calls the GET policy-bundle API in the policy-service
+// If policyName is empty, the full policy bundle would be fetched for given ownerID+context
+// If a policyName is provided, only that matching policy would be fetched for given ownerID+context+policyName
+func (c Client) FetchPolicyBundle(ownerID, context, policyName string) (interface{}, error) {
+ req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/owner/%s/context/%s/policy-bundle/%s", c.serverUrl, ownerID, context, policyName), nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct request: %v", err)
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var payload httpError
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, fmt.Errorf("unexpected status-code: %d", resp.StatusCode)
+ }
+ return nil, fmt.Errorf("unexpected status-code: %d - %s", resp.StatusCode, payload.Error)
+ }
+
+ var body interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("failed to decode response body: %v", err)
+ }
+
+ return body, nil
+}
+
+type DecisionQueryRequest struct {
+ Status string
+ After *time.Time
+ Before *time.Time
+ Branch string
+ ProjectID string
+ Offset int
+}
+
+// GetDecisionLogs calls the GET decision query API of policy-service. The endpoint accepts multiple filter values as
+// path query parameters (start-time, end-time, branch-name, project-id and offset).
+func (c Client) GetDecisionLogs(ownerID string, context string, request DecisionQueryRequest) ([]interface{}, error) {
+ req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/owner/%s/context/%s/decision", c.serverUrl, ownerID, context), nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct request: %v", err)
+ }
+
+ query := make(url.Values)
+ if request.Status != "" {
+ query.Set("status", fmt.Sprint(request.Status))
+ }
+ if request.After != nil {
+ query.Set("after", request.After.Format(time.RFC3339))
+ }
+ if request.Before != nil {
+ query.Set("before", request.Before.Format(time.RFC3339))
+ }
+ if request.Branch != "" {
+ query.Set("branch", fmt.Sprint(request.Branch))
+ }
+ if request.ProjectID != "" {
+ query.Set("project_id", fmt.Sprint(request.ProjectID))
+ }
+ if request.Offset > 0 {
+ query.Set("offset", fmt.Sprint(request.Offset))
+ }
+
+ req.URL.RawQuery = query.Encode()
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var payload httpError
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, fmt.Errorf("unexpected status-code: %d", resp.StatusCode)
+ }
+ return nil, fmt.Errorf("unexpected status-code: %d - %s", resp.StatusCode, payload.Error)
+ }
+
+ var body []interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("failed to decode response body: %v", err)
+ }
+
+ return body, nil
+}
+
+// GetDecisionLog calls the GET decision query API of policy-service for a DecisionID.
+// It also accepts a policyBundle bool param; If set to true will return only the policy bundle corresponding to that decision log.
+func (c Client) GetDecisionLog(ownerID string, context string, decisionID string, policyBundle bool) (interface{}, error) {
+ path := fmt.Sprintf("%s/api/v1/owner/%s/context/%s/decision/%s", c.serverUrl, ownerID, context, decisionID)
+ if policyBundle {
+ path += "/policy-bundle"
+ }
+ req, err := http.NewRequest("GET", path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct request: %v", err)
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var payload httpError
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, fmt.Errorf("unexpected status-code: %d", resp.StatusCode)
+ }
+ return nil, fmt.Errorf("unexpected status-code: %d - %s", resp.StatusCode, payload.Error)
+ }
+
+ var body interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("failed to decode response body: %v", err)
+ }
+
+ return body, nil
+}
+
+// DecisionRequest represents a request to Policy-Service to evaluate a given input against an organization's policies.
+// The context determines which policies to apply.
+type DecisionRequest struct {
+ Input string `json:"input"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
+}
+
+// GetSettings calls the GET decision-settings API of policy-service.
+func (c Client) GetSettings(ownerID string, context string) (interface{}, error) {
+ path := fmt.Sprintf("%s/api/v1/owner/%s/context/%s/decision/settings", c.serverUrl, ownerID, context)
+ req, err := http.NewRequest("GET", path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct request: %v", err)
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var payload httpError
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, fmt.Errorf("unexpected status-code: %d", resp.StatusCode)
+ }
+ return nil, fmt.Errorf("unexpected status-code: %d - %s", resp.StatusCode, payload.Error)
+ }
+
+ var body interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("failed to decode response body: %v", err)
+ }
+
+ return body, nil
+}
+
+// DecisionSettings represents a request to Policy-Service to configure decision settings.
+type DecisionSettings struct {
+ Enabled *bool `json:"enabled,omitempty"`
+}
+
+// SetSettings calls the PATCH decision-settings API of policy-service.
+func (c Client) SetSettings(ownerID string, context string, request DecisionSettings) (interface{}, error) {
+ payload, err := json.Marshal(request)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+ path := fmt.Sprintf("%s/api/v1/owner/%s/context/%s/decision/settings", c.serverUrl, ownerID, context)
+ req, err := http.NewRequest("PATCH", path, bytes.NewReader(payload))
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct request: %v", err)
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var payload httpError
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, fmt.Errorf("unexpected status-code: %d", resp.StatusCode)
+ }
+ return nil, fmt.Errorf("unexpected status-code: %d - %s", resp.StatusCode, payload.Error)
+ }
+
+ var body interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("failed to decode response body: %v", err)
+ }
+
+ return body, nil
+}
+
+// MakeDecision sends a requests to Policy-Service public decision endpoint and returns the decision response
+func (c Client) MakeDecision(ownerID string, context string, req DecisionRequest) (*cpa.Decision, error) {
+ payload, err := json.Marshal(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ endpoint := fmt.Sprintf("%s/api/v1/owner/%s/context/%s/decision", c.serverUrl, ownerID, context)
+
+ request, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct request: %w", err)
+ }
+
+ request.Header.Set("Content-Length", strconv.Itoa(len(payload)))
+
+ resp, err := c.client.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get response: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ var payload httpError
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, fmt.Errorf("unexpected status-code: %d", resp.StatusCode)
+ }
+ return nil, fmt.Errorf("unexpected status-code: %d - %s", resp.StatusCode, payload.Error)
+ }
+
+ var body cpa.Decision
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("failed to decode response body: %w", err)
+ }
+
+ return &body, nil
+}
+
+// NewClient returns a new policy client that will use the provided settings.Config to automatically inject appropriate
+// Circle-Token authentication and other relevant CLI headers.
+func NewClient(baseURL string, config *settings.Config) *Client {
+ transport := config.HTTPClient.Transport
+ if transport == nil {
+ transport = http.DefaultTransport
+ }
+
+ // Throttling the client so that it cannot make more than 10 concurrent requests at time
+ sem := make(chan struct{}, 10)
+
+ config.HTTPClient.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
+ // Acquiring semaphore to respect throttling
+ sem <- struct{}{}
+
+ // releasing the semaphore after a second ensuring client doesn't make more than cap(sem)/second
+ time.AfterFunc(time.Second, func() { <-sem })
+
+ if config.Token != "" {
+ r.Header.Add("circle-token", config.Token)
+ }
+ r.Header.Add("Accept", "application/json")
+ r.Header.Add("Content-Type", "application/json")
+ r.Header.Add("User-Agent", version.UserAgent())
+ if commandStr := header.GetCommandStr(); commandStr != "" {
+ r.Header.Add("Circleci-Cli-Command", commandStr)
+ }
+ return transport.RoundTrip(r)
+ })
+
+ return &Client{
+ serverUrl: strings.TrimSuffix(baseURL, "/"),
+ client: config.HTTPClient,
+ }
+}
+
+// transportFunc is utility type for declaring a http.RoundTripper as a function literal
+type transportFunc func(*http.Request) (*http.Response, error)
+
+// RoundTrip implements the http.RoundTripper interface
+func (fn transportFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return fn(req)
+}
diff --git a/api/policy/policy_test.go b/api/policy/policy_test.go
new file mode 100644
index 000000000..a112c1e60
--- /dev/null
+++ b/api/policy/policy_test.go
@@ -0,0 +1,796 @@
+package policy
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/CircleCI-Public/circle-policy-agent/cpa"
+ "gotest.tools/v3/assert"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/CircleCI-Public/circleci-cli/version"
+)
+
+func TestClientFetchPolicyBundle(t *testing.T) {
+ t.Run("expected request", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("content-type"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/ownerId/context/config/policy-bundle/my_policy")
+
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte("{}"))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ _, err := client.FetchPolicyBundle("ownerId", "config", "my_policy")
+ assert.NilError(t, err)
+ })
+
+ t.Run("Fetch Policy Bundle - Forbidden", func(t *testing.T) {
+ expectedResponse := `{"error": "Forbidden"}`
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ policies, err := client.FetchPolicyBundle("ownerId", "config", "")
+ assert.Equal(t, policies, nil)
+ assert.Error(t, err, "unexpected status-code: 403 - Forbidden")
+ })
+
+ t.Run("Fetch Policy Bundle - Bad error json", func(t *testing.T) {
+ expectedResponse := `{"this is bad json": }`
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ policies, err := client.FetchPolicyBundle("ownerId", "config", "")
+ assert.Equal(t, policies, nil)
+ assert.Error(t, err, "unexpected status-code: 403")
+ })
+
+ t.Run("Fetch Policy Bundle - no policies", func(t *testing.T) {
+ expectedResponse := "{}"
+
+ var expectedResponseValue interface{}
+ assert.NilError(t, json.Unmarshal([]byte(expectedResponse), &expectedResponseValue))
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ policies, err := client.FetchPolicyBundle("ownerId", "config", "")
+ assert.DeepEqual(t, policies, expectedResponseValue)
+ assert.NilError(t, err)
+ })
+
+ t.Run("Fetch Policy Bundle - some policies", func(t *testing.T) {
+ expectedResponse := `[
+ {
+ "id": "60b7e1a5-c1d7-4422-b813-7a12d353d7c6",
+ "name": "policy_1",
+ "owner_id": "462d67f8-b232-4da4-a7de-0c86dd667d3f",
+ "context": "config",
+ "created_at": "2022-05-31T14:15:10.86097Z",
+ "modified_at": null
+ },
+ {
+ "id": "a917a0ab-ceb6-482d-9a4e-f2f6b8bdfdcd",
+ "name": "policy_2",
+ "owner_id": "462d67f8-b232-4da4-a7de-0c86dd667d3f",
+ "context": "config",
+ "created_at": "2022-05-31T14:15:23.582383Z",
+ "modified_at": "2022-05-31T14:15:46.72321Z"
+ }
+ ]`
+
+ var expectedResponseValue interface{}
+ assert.NilError(t, json.Unmarshal([]byte(expectedResponse), &expectedResponseValue))
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ policies, err := client.FetchPolicyBundle("ownerId", "config", "")
+ assert.DeepEqual(t, policies, expectedResponseValue)
+ assert.NilError(t, err)
+ })
+}
+
+func TestClientCreatePolicy(t *testing.T) {
+ t.Run("expected request", func(t *testing.T) {
+ req := CreatePolicyBundleRequest{
+ Policies: map[string]string{"policy_a": "package org"},
+ }
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("content-type"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/ownerId/context/config/policy-bundle")
+
+ var actual CreatePolicyBundleRequest
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&actual))
+ assert.DeepEqual(t, actual, req)
+
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte("{}"))
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: http.DefaultClient}
+ client := NewClient(svr.URL, config)
+
+ _, err := client.CreatePolicyBundle("ownerId", "config", req)
+ assert.NilError(t, err)
+ })
+
+ t.Run("expected dry request", func(t *testing.T) {
+ req := CreatePolicyBundleRequest{
+ Policies: map[string]string{"policy_a": "package org"},
+ DryRun: true,
+ }
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("content-type"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/ownerId/context/config/policy-bundle")
+ assert.Equal(t, r.URL.RawQuery, "dry=true")
+
+ var actual CreatePolicyBundleRequest
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&actual))
+ assert.DeepEqual(t, actual, CreatePolicyBundleRequest{Policies: req.Policies})
+
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte("{}"))
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: http.DefaultClient}
+ client := NewClient(svr.URL, config)
+
+ _, err := client.CreatePolicyBundle("ownerId", "config", req)
+ assert.NilError(t, err)
+ })
+
+ t.Run("unexpected status code", func(t *testing.T) {
+ expectedResponse := `{"error": "Forbidden"}`
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ _, err := client.CreatePolicyBundle("ownerId", "config", CreatePolicyBundleRequest{})
+ assert.Error(t, err, "unexpected status-code: 403 - Forbidden")
+ })
+}
+
+func TestClientGetDecisionLogs(t *testing.T) {
+ t.Run("expected request without any filters", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("content-type"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/ownerId/context/config/decision")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerId/context/config/decision")
+
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte("[]"))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ _, err := client.GetDecisionLogs("ownerId", "config", DecisionQueryRequest{})
+ assert.NilError(t, err)
+ })
+
+ t.Run("expected request without only one filter", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("content-type"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/ownerId/context/config/decision")
+ assert.Equal(t, r.URL.RawQuery, "project_id=projectIDValue")
+
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte("[]"))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ _, err := client.GetDecisionLogs("ownerId", "config", DecisionQueryRequest{ProjectID: "projectIDValue"})
+ assert.NilError(t, err)
+ })
+
+ t.Run("expected request with all filters", func(t *testing.T) {
+ testTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("content-type"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/ownerId/context/config/decision")
+ assert.Equal(
+ t,
+ r.URL.RawQuery,
+ "after=2000-01-01T00%3A00%3A00Z&before=2000-01-01T00%3A00%3A00Z&branch=branchValue&offset=42&project_id=projectIDValue&status=PASS",
+ )
+
+ assert.Equal(t, r.URL.Query().Get("before"), testTime.Format(time.RFC3339))
+ assert.Equal(t, r.URL.Query().Get("after"), testTime.Format(time.RFC3339))
+
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte("[]"))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ _, err := client.GetDecisionLogs("ownerId", "config", DecisionQueryRequest{
+ Status: "PASS",
+ After: &testTime,
+ Before: &testTime,
+ Branch: "branchValue",
+ ProjectID: "projectIDValue",
+ Offset: 42,
+ })
+ assert.NilError(t, err)
+ })
+
+ t.Run("Get Decision Logs - Bad Request", func(t *testing.T) {
+ expectedResponse := `{"error": "Offset: must be an integer number."}`
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ logs, err := client.GetDecisionLogs("ownerId", "config", DecisionQueryRequest{})
+ assert.Error(t, err, "unexpected status-code: 400 - Offset: must be an integer number.")
+ assert.Equal(t, len(logs), 0)
+ })
+
+ t.Run("Get Decision Logs - Forbidden", func(t *testing.T) {
+ expectedResponse := `{"error": "Forbidden"}`
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ logs, err := client.GetDecisionLogs("ownerId", "config", DecisionQueryRequest{})
+ assert.Error(t, err, "unexpected status-code: 403 - Forbidden")
+ assert.Equal(t, len(logs), 0)
+ })
+
+ t.Run("Get Decision Logs - no decision logs", func(t *testing.T) {
+ expectedResponse := "[]"
+
+ var expectedResponseValue []interface{}
+ assert.NilError(t, json.Unmarshal([]byte(expectedResponse), &expectedResponseValue))
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ logs, err := client.GetDecisionLogs("ownerId", "config", DecisionQueryRequest{})
+ assert.DeepEqual(t, logs, expectedResponseValue)
+ assert.NilError(t, err)
+ })
+
+ t.Run("Get Decision Logs - some logs", func(t *testing.T) {
+ expectedResponse := `[
+ {
+ "created_at": "2022-08-11T09:20:40.674594-04:00",
+ "decision": {
+ "enabled_rules": [
+ "branch_is_main"
+ ],
+ "status": "PASS"
+ },
+ "metadata": {},
+ "policies": [
+ "8c69adc542bcfd6e65f5d5a2b6a4e3764480db2253cd075d0954e64a1f827a9c695c916d5a49302991df781447b3951410824dce8a8282d11ed56302272cf6fb",
+ "3124131001ec20b4b524260ababa6411190a1bc9c5ac3219ccc2d21109fc5faf4bb9f7bbe38f3f798d9c232d68564390e0ca560877711f3f2ff7f89e10eef685"
+ ],
+ "time_taken_ms": 4
+ },
+ {
+ "created_at": "2022-08-11T09:21:31.66168-04:00",
+ "decision": {
+ "enabled_rules": [
+ "branch_is_main"
+ ],
+ "status": "PASS"
+ },
+ "metadata": {},
+ "policies": [
+ "8c69adc542bcfd6e65f5d5a2b6a4e3764480db2253cd075d0954e64a1f827a9c695c916d5a49302991df781447b3951410824dce8a8282d11ed56302272cf6fb",
+ "3124131001ec20b4b524260ababa6411190a1bc9c5ac3219ccc2d21109fc5faf4bb9f7bbe38f3f798d9c232d68564390e0ca560877711f3f2ff7f89e10eef685"
+ ],
+ "time_taken_ms": 7
+ }
+]`
+ var expectedResponseValue []interface{}
+ assert.NilError(t, json.Unmarshal([]byte(expectedResponse), &expectedResponseValue))
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ logs, err := client.GetDecisionLogs("ownerId", "config", DecisionQueryRequest{})
+ assert.DeepEqual(t, logs, expectedResponseValue)
+ assert.NilError(t, err)
+ })
+}
+
+func TestClientGetDecisionLog(t *testing.T) {
+ t.Run("expected request with policyBundle=false", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("content-type"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/ownerId/context/config/decision/decisionID")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerId/context/config/decision/decisionID")
+
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte("[]"))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ _, err := client.GetDecisionLog("ownerId", "config", "decisionID", false)
+ assert.NilError(t, err)
+ })
+
+ t.Run("expected request without policyBundle=true", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("content-type"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+ assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
+
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/ownerId/context/config/decision/decisionID/policy-bundle")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerId/context/config/decision/decisionID/policy-bundle")
+
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte("[]"))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ _, err := client.GetDecisionLog("ownerId", "config", "decisionID", true)
+ assert.NilError(t, err)
+ })
+
+ t.Run("Get Decision Log - Bad Request", func(t *testing.T) {
+ expectedResponse := `{"error": "some error"}`
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ log, err := client.GetDecisionLog("ownerId", "config", "decisionID", false)
+ assert.Error(t, err, "unexpected status-code: 400 - some error")
+ assert.Equal(t, log, nil)
+ })
+
+ t.Run("Get Decision Log - Forbidden", func(t *testing.T) {
+ expectedResponse := `{"error": "Forbidden"}`
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ log, err := client.GetDecisionLog("ownerId", "config", "decisionID", false)
+ assert.Error(t, err, "unexpected status-code: 403 - Forbidden")
+ assert.Equal(t, log, nil)
+ })
+
+ t.Run("Get Decision Log - decision log not found", func(t *testing.T) {
+ expectedResponse := `{"error": "decision log not found"}`
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ log, err := client.GetDecisionLog("ownerId", "config", "decisionID", false)
+ assert.Error(t, err, "unexpected status-code: 404 - decision log not found")
+ assert.Equal(t, log, nil)
+ })
+
+ t.Run("Get Decision Log - successfully finds decision log", func(t *testing.T) {
+ expectedResponse := `{
+ "id": "fdc5311d-6d4d-480c-8ba8-b86b215ee86a",
+ "created_at": "2022-08-11T09:20:40.674594-04:00",
+ "decision": {
+ "enabled_rules": [
+ "branch_is_main"
+ ],
+ "status": "PASS"
+ },
+ "metadata": {},
+ "policies": {
+ "policy_name1": "8c69adc542bcfd6e65f5d5a2b6a4e3764480db2253cd075d0954e64a1f827a9c695c916d5a49302991df781447b3951410824dce8a8282d11ed56302272cf6fb",
+ "policy_name2": "3124131001ec20b4b524260ababa6411190a1bc9c5ac3219ccc2d21109fc5faf4bb9f7bbe38f3f798d9c232d68564390e0ca560877711f3f2ff7f89e10eef685"
+ },
+ "time_taken_ms": 4
+ }`
+ var expectedResponseValue interface{}
+ assert.NilError(t, json.Unmarshal([]byte(expectedResponse), &expectedResponseValue))
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, err := w.Write([]byte(expectedResponse))
+ assert.NilError(t, err)
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
+ client := NewClient(svr.URL, config)
+
+ log, err := client.GetDecisionLog("ownerId", "config", "fdc5311d-6d4d-480c-8ba8-b86b215ee86a", false)
+ assert.DeepEqual(t, log, expectedResponseValue)
+ assert.NilError(t, err)
+ })
+}
+
+func TestMakeDecision(t *testing.T) {
+ testcases := []struct {
+ Name string
+ OwnerID string
+ Request DecisionRequest
+ Handler http.HandlerFunc
+ ExpectedError error
+ ExpectedDecision interface{}
+ }{
+ {
+ Name: "sends expected request",
+ OwnerID: "test-owner",
+ Request: DecisionRequest{
+ Input: "test-input",
+ },
+ Handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision")
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.Header.Get("Circle-Token"), "test-token")
+
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "input": "test-input",
+ })
+
+ _ = json.NewEncoder(w).Encode(map[string]string{"status": "PASS"})
+ },
+ ExpectedDecision: &cpa.Decision{Status: cpa.StatusPass},
+ },
+ {
+ Name: "unexpected status code",
+ OwnerID: "test-owner",
+ Request: DecisionRequest{},
+ Handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(400)
+ _, _ = io.WriteString(w, `{"error":"that was a bad request!"}`)
+ },
+ ExpectedError: errors.New("unexpected status-code: 400 - that was a bad request!"),
+ },
+
+ {
+ Name: "unexpected status code no body",
+ OwnerID: "test-owner",
+ Request: DecisionRequest{},
+ Handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(204)
+ },
+ ExpectedError: errors.New("unexpected status-code: 204"),
+ },
+ {
+ Name: "bad decoding",
+ OwnerID: "test-owner",
+ Request: DecisionRequest{},
+ Handler: func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "not a json response")
+ },
+ ExpectedError: errors.New("failed to decode response body: invalid character 'o' in literal null (expecting 'u')"),
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ svr := httptest.NewServer(tc.Handler)
+ defer svr.Close()
+
+ client := NewClient(svr.URL, &settings.Config{Token: "test-token", HTTPClient: http.DefaultClient})
+
+ decision, err := client.MakeDecision(tc.OwnerID, "config", tc.Request)
+ if tc.ExpectedError == nil {
+ assert.NilError(t, err)
+ } else {
+ assert.Error(t, err, tc.ExpectedError.Error())
+ return
+ }
+
+ assert.DeepEqual(t, decision, tc.ExpectedDecision)
+ })
+ }
+}
+
+func TestGetSettings(t *testing.T) {
+ testcases := []struct {
+ Name string
+ OwnerID string
+ Handler http.HandlerFunc
+ ExpectedError error
+ ExpectedSettings interface{}
+ }{
+ {
+ Name: "gets expected response",
+ OwnerID: "test-owner",
+ Handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision/settings")
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.Header.Get("Circle-Token"), "test-token")
+ _ = json.NewEncoder(w).Encode(interface{}(`{"enabled": true}`))
+ },
+ ExpectedSettings: interface{}(`{"enabled": true}`),
+ },
+ {
+ Name: "unexpected status code",
+ OwnerID: "test-owner",
+ Handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(400)
+ _, _ = io.WriteString(w, `{"error":"that was a bad request!"}`)
+ },
+ ExpectedError: errors.New("unexpected status-code: 400 - that was a bad request!"),
+ },
+
+ {
+ Name: "unexpected status code no body",
+ OwnerID: "test-owner",
+ Handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(204)
+ },
+ ExpectedError: errors.New("unexpected status-code: 204"),
+ },
+ {
+ Name: "bad decoding",
+ OwnerID: "test-owner",
+ Handler: func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "not a json response")
+ },
+ ExpectedError: errors.New("failed to decode response body: invalid character 'o' in literal null (expecting 'u')"),
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ svr := httptest.NewServer(tc.Handler)
+ defer svr.Close()
+
+ client := NewClient(svr.URL, &settings.Config{Token: "test-token", HTTPClient: http.DefaultClient})
+
+ settings, err := client.GetSettings(tc.OwnerID, "config")
+ if tc.ExpectedError == nil {
+ assert.NilError(t, err)
+ } else {
+ assert.Error(t, err, tc.ExpectedError.Error())
+ return
+ }
+
+ assert.DeepEqual(t, settings, tc.ExpectedSettings)
+ })
+ }
+}
+
+func TestSetSettings(t *testing.T) {
+ trueVar := true
+ falseVar := false
+
+ testcases := []struct {
+ Name string
+ OwnerID string
+ Settings DecisionSettings
+ Handler http.HandlerFunc
+ ExpectedError error
+ ExpectedStatus int
+ ExpectedResponse interface{}
+ }{
+ {
+ Name: "sends expected request (enabled=true)",
+ OwnerID: "test-owner",
+ Settings: DecisionSettings{Enabled: &trueVar},
+ Handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision/settings")
+ assert.Equal(t, r.Method, "PATCH")
+ assert.Equal(t, r.Header.Get("Circle-Token"), "test-token")
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "enabled": true,
+ })
+ _ = json.NewEncoder(w).Encode(interface{}(`{"enabled": true}`))
+ },
+ ExpectedStatus: 200,
+ ExpectedResponse: interface{}(`{"enabled": true}`),
+ },
+ {
+ Name: "sends expected request (enabled=false)",
+ OwnerID: "test-owner",
+ Settings: DecisionSettings{Enabled: &falseVar},
+ Handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision/settings")
+ assert.Equal(t, r.Method, "PATCH")
+ assert.Equal(t, r.Header.Get("Circle-Token"), "test-token")
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "enabled": false,
+ })
+ _ = json.NewEncoder(w).Encode(interface{}(`{"enabled": false}`))
+ },
+ ExpectedStatus: 200,
+ ExpectedResponse: interface{}(`{"enabled": false}`),
+ },
+ {
+ Name: "sends expected request (enabled=nil)",
+ OwnerID: "test-owner",
+ Settings: DecisionSettings{Enabled: nil},
+ Handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision/settings")
+ assert.Equal(t, r.Method, "PATCH")
+ assert.Equal(t, r.Header.Get("Circle-Token"), "test-token")
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+ assert.DeepEqual(t, payload, map[string]interface{}{})
+ _ = json.NewEncoder(w).Encode(interface{}(`{}`))
+ },
+ ExpectedStatus: 200,
+ ExpectedResponse: interface{}(`{}`),
+ },
+ {
+ Name: "unexpected status code",
+ OwnerID: "test-owner",
+ Handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(400)
+ _, _ = io.WriteString(w, `{"error":"that was a bad request!"}`)
+ },
+ ExpectedError: errors.New("unexpected status-code: 400 - that was a bad request!"),
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ svr := httptest.NewServer(tc.Handler)
+ defer svr.Close()
+
+ client := NewClient(svr.URL, &settings.Config{Token: "test-token", HTTPClient: http.DefaultClient})
+
+ response, err := client.SetSettings(tc.OwnerID, "config", tc.Settings)
+ if tc.ExpectedError == nil {
+ assert.NilError(t, err)
+ } else {
+ assert.Error(t, err, tc.ExpectedError.Error())
+ return
+ }
+ assert.DeepEqual(t, response, tc.ExpectedResponse)
+ })
+ }
+}
diff --git a/api/project/project.go b/api/project/project.go
new file mode 100644
index 000000000..d1d745959
--- /dev/null
+++ b/api/project/project.go
@@ -0,0 +1,21 @@
+package project
+
+// ProjectEnvironmentVariable is a Environment Variable of a Project
+type ProjectEnvironmentVariable struct {
+ Name string
+ Value string
+}
+
+// ProjectInfo is the info of a Project
+type ProjectInfo struct {
+ Id string
+}
+
+// ProjectClient is the interface to interact with project and it's
+// components.
+type ProjectClient interface {
+ ProjectInfo(vcs, org, project string) (*ProjectInfo, error)
+ ListAllEnvironmentVariables(vcs, org, project string) ([]*ProjectEnvironmentVariable, error)
+ GetEnvironmentVariable(vcs, org, project, envName string) (*ProjectEnvironmentVariable, error)
+ CreateEnvironmentVariable(vcs, org, project string, v ProjectEnvironmentVariable) (*ProjectEnvironmentVariable, error)
+}
diff --git a/api/project/project_rest.go b/api/project/project_rest.go
new file mode 100644
index 000000000..9116c2e0d
--- /dev/null
+++ b/api/project/project_rest.go
@@ -0,0 +1,171 @@
+package project
+
+import (
+ "fmt"
+ "net/url"
+
+ "github.com/CircleCI-Public/circleci-cli/api/rest"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+)
+
+type projectRestClient struct {
+ client *rest.Client
+}
+
+var _ ProjectClient = &projectRestClient{}
+
+type listProjectEnvVarsParams struct {
+ vcs string
+ org string
+ project string
+ pageToken string
+}
+
+type projectEnvVarResponse struct {
+ Name string
+ Value string
+}
+
+type listAllProjectEnvVarsResponse struct {
+ Items []projectEnvVarResponse
+ NextPageToken string `json:"next_page_token"`
+}
+
+type createProjectEnvVarRequest struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+// projectInfo is the info returned by "Get a project" API endpoint.
+// This struct does not contain all the fields returned by the API.
+type projectInfo struct {
+ Id string `json:"id"`
+}
+
+// NewProjectRestClient returns a new projectRestClient satisfying the api.ProjectInterface
+// interface via the REST API.
+func NewProjectRestClient(config settings.Config) (*projectRestClient, error) {
+ client := &projectRestClient{
+ client: rest.NewFromConfig(config.Host, &config),
+ }
+ return client, nil
+}
+
+// ListAllEnvironmentVariables returns all of the environment variables owned by the
+// given project. Note that pagination is not supported - we get all
+// pages of env vars and return them all.
+func (p *projectRestClient) ListAllEnvironmentVariables(vcs, org, project string) ([]*ProjectEnvironmentVariable, error) {
+ res := make([]*ProjectEnvironmentVariable, 0)
+ var nextPageToken string
+ for {
+ resp, err := p.listEnvironmentVariables(&listProjectEnvVarsParams{
+ vcs: vcs,
+ org: org,
+ project: project,
+ pageToken: nextPageToken,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ for _, ev := range resp.Items {
+ res = append(res, &ProjectEnvironmentVariable{
+ Name: ev.Name,
+ Value: ev.Value,
+ })
+ }
+
+ if resp.NextPageToken == "" {
+ break
+ }
+
+ nextPageToken = resp.NextPageToken
+ }
+ return res, nil
+}
+
+func (c *projectRestClient) listEnvironmentVariables(params *listProjectEnvVarsParams) (*listAllProjectEnvVarsResponse, error) {
+ path := fmt.Sprintf("project/%s/%s/%s/envvar", params.vcs, params.org, params.project)
+ urlParams := url.Values{}
+ if params.pageToken != "" {
+ urlParams.Add("page-token", params.pageToken)
+ }
+
+ req, err := c.client.NewRequest("GET", &url.URL{Path: path, RawQuery: urlParams.Encode()}, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp listAllProjectEnvVarsResponse
+ _, err = c.client.DoRequest(req, &resp)
+ if err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+// GetEnvironmentVariable retrieves and returns a variable with the given name.
+// If the response status code is 404, nil is returned.
+func (c *projectRestClient) GetEnvironmentVariable(vcs string, org string, project string, envName string) (*ProjectEnvironmentVariable, error) {
+ path := fmt.Sprintf("project/%s/%s/%s/envvar/%s", vcs, org, project, envName)
+ req, err := c.client.NewRequest("GET", &url.URL{Path: path}, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp projectEnvVarResponse
+ code, err := c.client.DoRequest(req, &resp)
+ if err != nil {
+ if code == 404 {
+ // Note: 404 may mean that the project isn't found.
+ // The cause can't be distinguished except by the response text.
+ return nil, nil
+ }
+ return nil, err
+ }
+ return &ProjectEnvironmentVariable{
+ Name: resp.Name,
+ Value: resp.Value,
+ }, nil
+}
+
+// CreateEnvironmentVariable creates a variable on the given project.
+// This returns the variable if successfully created.
+func (c *projectRestClient) CreateEnvironmentVariable(vcs string, org string, project string, v ProjectEnvironmentVariable) (*ProjectEnvironmentVariable, error) {
+ path := fmt.Sprintf("project/%s/%s/%s/envvar", vcs, org, project)
+ req, err := c.client.NewRequest("POST", &url.URL{Path: path}, &createProjectEnvVarRequest{
+ Name: v.Name,
+ Value: v.Value,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ var resp projectEnvVarResponse
+ _, err = c.client.DoRequest(req, &resp)
+ if err != nil {
+ return nil, err
+ }
+ return &ProjectEnvironmentVariable{
+ Name: resp.Name,
+ Value: resp.Value,
+ }, nil
+}
+
+// ProjectInfo retrieves and returns the project info.
+func (c *projectRestClient) ProjectInfo(vcs string, org string, project string) (*ProjectInfo, error) {
+ path := fmt.Sprintf("project/%s/%s/%s", vcs, org, project)
+ req, err := c.client.NewRequest("GET", &url.URL{Path: path}, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp projectInfo
+ _, err = c.client.DoRequest(req, &resp)
+ if err != nil {
+ return nil, err
+ }
+ return &ProjectInfo{
+ Id: resp.Id,
+ }, nil
+}
diff --git a/api/project/project_rest_test.go b/api/project/project_rest_test.go
new file mode 100644
index 000000000..0badf5a9a
--- /dev/null
+++ b/api/project/project_rest_test.go
@@ -0,0 +1,395 @@
+package project_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "testing"
+
+ "gotest.tools/v3/assert"
+
+ "github.com/CircleCI-Public/circleci-cli/api/project"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/CircleCI-Public/circleci-cli/version"
+)
+
+func getProjectRestClient(server *httptest.Server) (project.ProjectClient, error) {
+ client := &http.Client{}
+
+ return project.NewProjectRestClient(settings.Config{
+ RestEndpoint: "api/v2",
+ Host: server.URL,
+ HTTPClient: client,
+ Token: "token",
+ })
+}
+
+func Test_projectRestClient_ListAllEnvironmentVariables(t *testing.T) {
+ const (
+ vcsType = "github"
+ orgName = "test-org"
+ projName = "test-proj"
+ )
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ want []*project.ProjectEnvironmentVariable
+ wantErr bool
+ }{
+ {
+ name: "Should handle a successful request with ListAllEnvironmentVariables",
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "token")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/project/%s/%s/%s/envvar", vcsType, orgName, projName))
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(`
+ {
+ "items": [{
+ "name": "foo",
+ "value": "xxxx1234"
+ }],
+ "next_page_token": ""
+ }`))
+ assert.NilError(t, err)
+ },
+ want: []*project.ProjectEnvironmentVariable{
+ {
+ Name: "foo",
+ Value: "xxxx1234",
+ },
+ },
+ },
+ {
+ name: "Should handle a request containing next_page_token with ListAllEnvironmentVariables",
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ u, err := url.ParseQuery(r.URL.RawQuery)
+ assert.NilError(t, err)
+
+ w.Header().Set("content-type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ if tk := u.Get("page-token"); tk == "" {
+ _, err := w.Write([]byte(`
+ {
+ "items": [
+ {
+ "name": "foo1",
+ "value": "xxxx1234"
+ },
+ {
+ "name": "foo2",
+ "value": "xxxx2345"
+ }
+ ],
+ "next_page_token": "pagetoken"
+ }`))
+ assert.NilError(t, err)
+ } else {
+ assert.Equal(t, tk, "pagetoken")
+ _, err := w.Write([]byte(`
+ {
+ "items": [
+ {
+ "name": "bar1",
+ "value": "xxxxabcd"
+ },
+ {
+ "name": "bar2",
+ "value": "xxxxbcde"
+ }
+ ],
+ "next_page_token": ""
+ }`))
+ assert.NilError(t, err)
+ }
+ },
+ want: []*project.ProjectEnvironmentVariable{
+ {
+ Name: "foo1",
+ Value: "xxxx1234",
+ },
+ {
+ Name: "foo2",
+ Value: "xxxx2345",
+ },
+ {
+ Name: "bar1",
+ Value: "xxxxabcd",
+ },
+ {
+ Name: "bar2",
+ Value: "xxxxbcde",
+ },
+ },
+ },
+ {
+ name: "Should handle an error request with ListAllEnvironmentVariables",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("content-type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, err := w.Write([]byte(`{"message": "error"}`))
+ assert.NilError(t, err)
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(tt.handler)
+ defer server.Close()
+
+ p, err := getProjectRestClient(server)
+ assert.NilError(t, err)
+
+ got, err := p.ListAllEnvironmentVariables(vcsType, orgName, projName)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("projectRestClient.ListAllEnvironmentVariables() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("projectRestClient.ListAllEnvironmentVariables() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_projectRestClient_GetEnvironmentVariable(t *testing.T) {
+ const (
+ vcsType = "github"
+ orgName = "test-org"
+ projName = "test-proj"
+ )
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ envName string
+ want *project.ProjectEnvironmentVariable
+ wantErr bool
+ }{
+ {
+ name: "Should handle a successful request",
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "token")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/project/%s/%s/%s/envvar/test1", vcsType, orgName, projName))
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(`
+ {
+ "name": "foo",
+ "value": "xxxx1234"
+ }`))
+ assert.NilError(t, err)
+ },
+ envName: "test1",
+ want: &project.ProjectEnvironmentVariable{
+ Name: "foo",
+ Value: "xxxx1234",
+ },
+ },
+ {
+ name: "Should handle an error request",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("content-type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, err := w.Write([]byte(`{"message": "error"}`))
+ assert.NilError(t, err)
+ },
+ wantErr: true,
+ },
+ {
+ name: "Should handle an 404 error as a valid request",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("content-type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, err := w.Write([]byte(`{"message": "Environment variable not found."}`))
+ assert.NilError(t, err)
+ },
+ want: nil,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(tt.handler)
+ defer server.Close()
+
+ p, err := getProjectRestClient(server)
+ assert.NilError(t, err)
+
+ got, err := p.GetEnvironmentVariable(vcsType, orgName, projName, tt.envName)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("projectRestClient.GetEnvironmentVariable() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("projectRestClient.GetEnvironmentVariable() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_projectRestClient_CreateEnvironmentVariable(t *testing.T) {
+ const (
+ vcsType = "github"
+ orgName = "test-org"
+ projName = "test-proj"
+ )
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ variable project.ProjectEnvironmentVariable
+ want *project.ProjectEnvironmentVariable
+ wantErr bool
+ }{
+ {
+ name: "Should handle a successful request",
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "token")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/project/%s/%s/%s/envvar", vcsType, orgName, projName))
+ var pv project.ProjectEnvironmentVariable
+ err := json.NewDecoder(r.Body).Decode(&pv)
+ assert.NilError(t, err)
+ assert.Equal(t, pv, project.ProjectEnvironmentVariable{
+ Name: "foo",
+ Value: "test1234",
+ })
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, err = w.Write([]byte(`
+ {
+ "name": "foo",
+ "value": "xxxx1234"
+ }`))
+ assert.NilError(t, err)
+ },
+ variable: project.ProjectEnvironmentVariable{
+ Name: "foo",
+ Value: "test1234",
+ },
+ want: &project.ProjectEnvironmentVariable{
+ Name: "foo",
+ Value: "xxxx1234",
+ },
+ },
+ {
+ name: "Should handle an error request",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("content-type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, err := w.Write([]byte(`{"message": "error"}`))
+ assert.NilError(t, err)
+ },
+ variable: project.ProjectEnvironmentVariable{
+ Name: "bar",
+ Value: "testbar",
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(tt.handler)
+ defer server.Close()
+
+ p, err := getProjectRestClient(server)
+ assert.NilError(t, err)
+
+ got, err := p.CreateEnvironmentVariable(vcsType, orgName, projName, tt.variable)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("projectRestClient.CreateEnvironmentVariable() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("projectRestClient.CreateEnvironmentVariable() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_projectRestClient_ProjectInfo(t *testing.T) {
+ const (
+ vcsType = "github"
+ orgName = "test-org"
+ projName = "test-proj"
+ )
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ want *project.ProjectInfo
+ wantErr bool
+ }{
+ {
+ name: "Should handle a successful request",
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Header.Get("circle-token"), "token")
+ assert.Equal(t, r.Header.Get("accept"), "application/json")
+ assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
+
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/project/%s/%s/%s", vcsType, orgName, projName))
+ br := r.Body
+ b, err := io.ReadAll(br)
+ assert.NilError(t, err)
+ assert.Equal(t, string(b), "")
+ assert.NilError(t, br.Close())
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, err = w.Write([]byte(`
+ {
+ "id": "this-is-the-id"
+ }`))
+ assert.NilError(t, err)
+ },
+ want: &project.ProjectInfo{
+ Id: "this-is-the-id",
+ },
+ },
+ {
+ name: "Should handle an error request",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("content-type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, err := w.Write([]byte(`{"message": "error"}`))
+ assert.NilError(t, err)
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(tt.handler)
+ defer server.Close()
+
+ p, err := getProjectRestClient(server)
+ assert.NilError(t, err)
+
+ got, err := p.ProjectInfo(vcsType, orgName, projName)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("projectRestClient.ProjectInfo() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("projectRestClient.ProjectInfo() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/api/rest/client.go b/api/rest/client.go
index 3474d523a..2d1628f41 100644
--- a/api/rest/client.go
+++ b/api/rest/client.go
@@ -12,29 +12,41 @@ import (
"time"
"github.com/CircleCI-Public/circleci-cli/api/header"
+ "github.com/CircleCI-Public/circleci-cli/settings"
"github.com/CircleCI-Public/circleci-cli/version"
)
type Client struct {
- baseURL *url.URL
+ BaseURL *url.URL
circleToken string
client *http.Client
}
-func New(host, endpoint, circleToken string) *Client {
+func New(baseURL *url.URL, token string, httpClient *http.Client) *Client {
+ return &Client{
+ BaseURL: baseURL,
+ circleToken: token,
+ client: httpClient,
+ }
+}
+
+func NewFromConfig(host string, config *settings.Config) *Client {
// Ensure endpoint ends with a slash
+ endpoint := config.RestEndpoint
if !strings.HasSuffix(endpoint, "/") {
endpoint += "/"
}
- u, _ := url.Parse(host)
- return &Client{
- baseURL: u.ResolveReference(&url.URL{Path: endpoint}),
- circleToken: circleToken,
- client: &http.Client{
- Timeout: 10 * time.Second,
- },
- }
+ baseURL, _ := url.Parse(host)
+
+ client := config.HTTPClient
+ client.Timeout = 10 * time.Second
+
+ return New(
+ baseURL.ResolveReference(&url.URL{Path: endpoint}),
+ config.Token,
+ client,
+ )
}
func (c *Client) NewRequest(method string, u *url.URL, payload interface{}) (req *http.Request, err error) {
@@ -48,13 +60,20 @@ func (c *Client) NewRequest(method string, u *url.URL, payload interface{}) (req
}
}
- req, err = http.NewRequest(method, c.baseURL.ResolveReference(u).String(), r)
+ req, err = http.NewRequest(method, c.BaseURL.ResolveReference(u).String(), r)
if err != nil {
return nil, err
}
- req.Header.Set("Circle-Token", c.circleToken)
- req.Header.Set("Accept-Type", "application/json")
+ c.enrichRequestHeaders(req, payload)
+ return req, nil
+}
+
+func (c *Client) enrichRequestHeaders(req *http.Request, payload interface{}) {
+ if c.circleToken != "" {
+ req.Header.Set("Circle-Token", c.circleToken)
+ }
+ req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", version.UserAgent())
commandStr := header.GetCommandStr()
if commandStr != "" {
@@ -63,13 +82,12 @@ func (c *Client) NewRequest(method string, u *url.URL, payload interface{}) (req
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
-
- return req, nil
}
func (c *Client) DoRequest(req *http.Request, resp interface{}) (statusCode int, err error) {
httpResp, err := c.client.Do(req)
if err != nil {
+ fmt.Printf("failed to make http request: %s\n", err.Error())
return 0, err
}
defer httpResp.Body.Close()
@@ -99,8 +117,8 @@ func (c *Client) DoRequest(req *http.Request, resp interface{}) (statusCode int,
}
type HTTPError struct {
- Code int
- Message string
+ Code int
+ Message string
}
func (e *HTTPError) Error() string {
diff --git a/api/rest/client_test.go b/api/rest/client_test.go
index 335a97322..30f552092 100644
--- a/api/rest/client_test.go
+++ b/api/rest/client_test.go
@@ -9,9 +9,9 @@ import (
"sync"
"testing"
- "gotest.tools/v3/assert"
- "gotest.tools/v3/assert/cmp"
+ "github.com/stretchr/testify/assert"
+ "github.com/CircleCI-Public/circleci-cli/settings"
"github.com/CircleCI-Public/circleci-cli/version"
)
@@ -29,29 +29,29 @@ func TestClient_DoRequest(t *testing.T) {
A: "aaa",
B: 123,
})
- assert.NilError(t, err)
+ assert.Nil(t, err)
resp := make(map[string]interface{})
statusCode, err := c.DoRequest(r, &resp)
- assert.NilError(t, err)
+ assert.Nil(t, err)
assert.Equal(t, statusCode, http.StatusCreated)
- assert.Check(t, cmp.DeepEqual(resp, map[string]interface{}{
+ assert.Equal(t, resp, map[string]interface{}{
"key": "value",
- }))
+ })
})
t.Run("Check request", func(t *testing.T) {
- assert.Check(t, cmp.Equal(fix.URL(), url.URL{Path: "/api/v2/my/endpoint"}))
- assert.Check(t, cmp.Equal(fix.Method(), "PUT"))
- assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
+ assert.Equal(t, fix.URL(), url.URL{Path: "/api/v2/my/endpoint"})
+ assert.Equal(t, fix.Method(), "PUT")
+ assert.Equal(t, fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"Content-Length": {"20"},
"Content-Type": {"application/json"},
"User-Agent": {version.UserAgent()},
- }))
- assert.Check(t, cmp.Equal(fix.Body(), `{"A":"aaa","B":123}`+"\n"))
+ })
+ assert.Equal(t, fix.Body(), `{"A":"aaa","B":123}`+"\n")
})
})
@@ -61,26 +61,26 @@ func TestClient_DoRequest(t *testing.T) {
defer cleanup()
t.Run("Check result", func(t *testing.T) {
- r, err := c.NewRequest("GET", &url.URL{Path: "my/error/endpoint"}, nil)
- assert.NilError(t, err)
+ r, err := c.NewRequest(http.MethodGet, &url.URL{Path: "my/error/endpoint"}, nil)
+ assert.Nil(t, err)
resp := make(map[string]interface{})
statusCode, err := c.DoRequest(r, &resp)
assert.Error(t, err, "the error message")
assert.Equal(t, statusCode, http.StatusBadRequest)
- assert.Check(t, cmp.DeepEqual(resp, map[string]interface{}{}))
+ assert.Equal(t, resp, map[string]interface{}{})
})
t.Run("Check request", func(t *testing.T) {
- assert.Check(t, cmp.Equal(fix.URL(), url.URL{Path: "/api/v2/my/error/endpoint"}))
- assert.Check(t, cmp.Equal(fix.Method(), "GET"))
- assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
+ assert.Equal(t, fix.URL(), url.URL{Path: "/api/v2/my/error/endpoint"})
+ assert.Equal(t, fix.Method(), http.MethodGet)
+ assert.Equal(t, fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"User-Agent": {version.UserAgent()},
- }))
- assert.Check(t, cmp.Equal(fix.Body(), ""))
+ })
+ assert.Equal(t, fix.Body(), "")
})
})
@@ -90,30 +90,92 @@ func TestClient_DoRequest(t *testing.T) {
defer cleanup()
t.Run("Check result", func(t *testing.T) {
- r, err := c.NewRequest("GET", &url.URL{Path: "path"}, nil)
- assert.NilError(t, err)
+ r, err := c.NewRequest(http.MethodGet, &url.URL{Path: "path"}, nil)
+ assert.Nil(t, err)
resp := make(map[string]interface{})
statusCode, err := c.DoRequest(r, &resp)
- assert.NilError(t, err)
+ assert.Nil(t, err)
assert.Equal(t, statusCode, http.StatusCreated)
- assert.Check(t, cmp.DeepEqual(resp, map[string]interface{}{
+ assert.Equal(t, resp, map[string]interface{}{
"a": "abc",
"b": true,
- }))
+ })
})
t.Run("Check request", func(t *testing.T) {
- assert.Check(t, cmp.Equal(fix.URL(), url.URL{Path: "/api/v2/path"}))
- assert.Check(t, cmp.Equal(fix.Method(), "GET"))
- assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
+ assert.Equal(t, fix.URL(), url.URL{Path: "/api/v2/path"})
+ assert.Equal(t, fix.Method(), http.MethodGet)
+ assert.Equal(t, fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"User-Agent": {version.UserAgent()},
- }))
- assert.Check(t, cmp.Equal(fix.Body(), ""))
+ })
+ assert.Equal(t, fix.Body(), "")
+ })
+ })
+}
+
+func TestAPIRequest(t *testing.T) {
+ fix := &fixture{}
+ c, cleanup := fix.Run(http.StatusCreated, `{"key": "value"}`)
+ defer cleanup()
+
+ t.Run("test new api request sets the default headers", func(t *testing.T) {
+ req, err := c.NewRequest("GET", &url.URL{}, struct{}{})
+ assert.Nil(t, err)
+ assert.Equal(t, req.Header.Get("User-Agent"), "circleci-cli/0.0.0-dev+dirty-local-tree (source)")
+ assert.Equal(t, req.Header.Get("Circle-Token"), c.circleToken)
+ assert.Equal(t, req.Header.Get("Accept"), "application/json")
+ })
+
+ type testPayload struct {
+ Message string
+ }
+
+ t.Run("test new api request sets the default headers", func(t *testing.T) {
+ req, err := c.NewRequest("GET", &url.URL{}, testPayload{Message: "hello"})
+ assert.Nil(t, err)
+ assert.Equal(t, req.Header.Get("Circleci-Cli-Command"), "")
+ assert.Equal(t, req.Header.Get("Content-Type"), "application/json")
+ })
+
+ t.Run("test new api request doesn't set content-type with empty payload", func(t *testing.T) {
+ req, err := c.NewRequest("GET", &url.URL{}, nil)
+ assert.Nil(t, err)
+ assert.Equal(t, req.Header.Get("Circleci-Cli-Command"), "")
+ assert.Equal(t, req.Header.Get("Content-Type"), "")
+ })
+
+ type Options struct {
+ OwnerID string `json:"owner_id,omitempty"`
+ PipelineParameters map[string]interface{} `json:"pipeline_parameters,omitempty"`
+ PipelineValues map[string]string `json:"pipeline_values,omitempty"`
+ }
+
+ type CompileConfigRequest struct {
+ ConfigYaml string `json:"config_yaml"`
+ Options Options `json:"options"`
+ }
+
+ t.Run("config compile and validate payloads have expected shape", func(t *testing.T) {
+ req, err := c.NewRequest("GET", &url.URL{}, CompileConfigRequest{
+ ConfigYaml: "test-config",
+ Options: Options{
+ OwnerID: "1234",
+ PipelineValues: map[string]string{
+ "key": "val",
+ },
+ },
})
+ assert.Nil(t, err)
+ assert.Equal(t, req.Header.Get("Circleci-Cli-Command"), "")
+ assert.Equal(t, req.Header.Get("Content-Type"), "application/json")
+
+ reqBody, _ := io.ReadAll(req.Body)
+ assert.Contains(t, string(reqBody), `"config_yaml":"test-config"`)
+ assert.Contains(t, string(reqBody), `"owner_id":"1234"`)
})
}
@@ -167,5 +229,13 @@ func (f *fixture) Run(statusCode int, respBody string) (c *Client, cleanup func(
})
server := httptest.NewServer(mux)
- return New(server.URL, "api/v2", "fake-token"), server.Close
+ cfg := &settings.Config{
+ Debug: false,
+ Token: "fake-token",
+ RestEndpoint: "api/v2",
+ Endpoint: "api/v2",
+ HTTPClient: http.DefaultClient,
+ }
+
+ return NewFromConfig(server.URL, cfg), server.Close
}
diff --git a/api/runner/runner.go b/api/runner/runner.go
index aa8ab335d..6cc006655 100644
--- a/api/runner/runner.go
+++ b/api/runner/runner.go
@@ -82,8 +82,13 @@ func (r *Runner) GetResourceClassesByNamespace(namespace string) ([]ResourceClas
return resp.Items, err
}
-func (r *Runner) DeleteResourceClass(id string) error {
- req, err := r.rc.NewRequest("DELETE", &url.URL{Path: "runner/resource/" + url.PathEscape(id)}, nil)
+func (r *Runner) DeleteResourceClass(id string, force bool) error {
+ path := "runner/resource/" + url.PathEscape(id)
+ if force {
+ path = fmt.Sprintf("runner/resource/%s/force", url.PathEscape(id))
+ }
+
+ req, err := r.rc.NewRequest("DELETE", &url.URL{Path: path}, nil)
if err != nil {
return err
}
diff --git a/api/runner/runner_test.go b/api/runner/runner_test.go
index 811a70a14..cd822ffd4 100644
--- a/api/runner/runner_test.go
+++ b/api/runner/runner_test.go
@@ -14,6 +14,7 @@ import (
"gotest.tools/v3/assert/cmp"
"github.com/CircleCI-Public/circleci-cli/api/rest"
+ "github.com/CircleCI-Public/circleci-cli/settings"
"github.com/CircleCI-Public/circleci-cli/version"
)
@@ -45,7 +46,7 @@ func TestRunner_CreateResourceClass(t *testing.T) {
assert.Check(t, cmp.Equal(fix.method, "POST"))
assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"Content-Length": {"86"},
"Content-Type": {"application/json"},
@@ -82,10 +83,10 @@ func TestRunner_GetResourceClassByName(t *testing.T) {
t.Run("Check request", func(t *testing.T) {
assert.Check(t, cmp.Equal(fix.URL(), url.URL{Path: "/api/v2/runner/resource", RawQuery: "namespace=the-namespace"}))
- assert.Check(t, cmp.Equal(fix.method, "GET"))
+ assert.Check(t, cmp.Equal(fix.method, http.MethodGet))
assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"User-Agent": {version.UserAgent()},
}))
@@ -133,10 +134,10 @@ func TestRunner_GetResourceClassesByNamespace(t *testing.T) {
t.Run("Check request", func(t *testing.T) {
assert.Check(t, cmp.Equal(fix.URL(), url.URL{Path: "/api/v2/runner/resource", RawQuery: "namespace=the-namespace"}))
- assert.Check(t, cmp.Equal(fix.method, "GET"))
+ assert.Check(t, cmp.Equal(fix.method, http.MethodGet))
assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"User-Agent": {version.UserAgent()},
}))
@@ -150,7 +151,7 @@ func TestRunner_DeleteResourceClass(t *testing.T) {
defer cleanup()
t.Run("Check resource-class is deleted", func(t *testing.T) {
- err := runner.DeleteResourceClass("51628548-4627-4813-9f9b-8cc9637ac879")
+ err := runner.DeleteResourceClass("51628548-4627-4813-9f9b-8cc9637ac879", false)
assert.NilError(t, err)
})
@@ -159,7 +160,7 @@ func TestRunner_DeleteResourceClass(t *testing.T) {
assert.Check(t, cmp.Equal(fix.method, "DELETE"))
assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"User-Agent": {version.UserAgent()},
}))
@@ -167,13 +168,32 @@ func TestRunner_DeleteResourceClass(t *testing.T) {
})
}
+func TestRunner_DeleteResourceClass_Force(t *testing.T) {
+ fix := fixture{}
+ runner, cleanup := fix.Run(http.StatusOK, ``)
+ defer cleanup()
+
+ err := runner.DeleteResourceClass("5a1ef22d-444b-45db-8e98-21d7c42fb80b", true)
+ assert.NilError(t, err)
+
+ assert.Check(t, cmp.Equal(fix.URL(), url.URL{Path: "/api/v2/runner/resource/5a1ef22d-444b-45db-8e98-21d7c42fb80b/force"}))
+ assert.Check(t, cmp.Equal(fix.method, "DELETE"))
+ assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
+ "Accept-Encoding": {"gzip"},
+ "Accept": {"application/json"},
+ "Circle-Token": {"fake-token"},
+ "User-Agent": {version.UserAgent()},
+ }))
+ assert.Check(t, cmp.Equal(fix.Body(), ``))
+}
+
func TestRunner_DeleteResourceClass_PathEscaping(t *testing.T) {
fix := fixture{}
runner, cleanup := fix.Run(http.StatusOK, ``)
defer cleanup()
t.Run("Check resource-class is deleted", func(t *testing.T) {
- err := runner.DeleteResourceClass("escape~,/;?~noescape~$&+:=@")
+ err := runner.DeleteResourceClass("escape~,/;?~noescape~$&+:=@", false)
assert.NilError(t, err)
})
@@ -212,7 +232,7 @@ func TestRunner_CreateToken(t *testing.T) {
assert.Check(t, cmp.Equal(fix.method, "POST"))
assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"Content-Length": {"80"},
"Content-Type": {"application/json"},
@@ -279,10 +299,10 @@ func TestRunner_GetRunnerTokensByResourceClass(t *testing.T) {
t.Run("Check request", func(t *testing.T) {
assert.Check(t, cmp.Equal(fix.URL(), url.URL{Path: "/api/v2/runner/token", RawQuery: "resource-class=the-namespace%2Fthe-resource-class"}))
- assert.Check(t, cmp.Equal(fix.method, "GET"))
+ assert.Check(t, cmp.Equal(fix.method, http.MethodGet))
assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"User-Agent": {version.UserAgent()},
}))
@@ -305,7 +325,7 @@ func TestRunner_DeleteToken(t *testing.T) {
assert.Check(t, cmp.Equal(fix.method, "DELETE"))
assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"User-Agent": {version.UserAgent()},
}))
@@ -390,10 +410,10 @@ func TestRunner_GetRunnerInstances_ByNamespace(t *testing.T) {
t.Run("Check request", func(t *testing.T) {
assert.Check(t, cmp.Equal(fix.URL(), url.URL{Path: "/api/v2/runner", RawQuery: "namespace=the-namespace"}))
- assert.Check(t, cmp.Equal(fix.method, "GET"))
+ assert.Check(t, cmp.Equal(fix.method, http.MethodGet))
assert.Check(t, cmp.DeepEqual(fix.Header(), http.Header{
"Accept-Encoding": {"gzip"},
- "Accept-Type": {"application/json"},
+ "Accept": {"application/json"},
"Circle-Token": {"fake-token"},
"User-Agent": {version.UserAgent()},
}))
@@ -470,5 +490,13 @@ func (f *fixture) Run(statusCode int, respBody string) (r *Runner, cleanup func(
})
server := httptest.NewServer(mux)
- return New(rest.New(server.URL, "api/v2", "fake-token")), server.Close
+ cfg := &settings.Config{
+ Debug: false,
+ Token: "fake-token",
+ RestEndpoint: "api/v2",
+ Endpoint: "api/v2",
+ HTTPClient: http.DefaultClient,
+ }
+
+ return New(rest.NewFromConfig(server.URL, cfg)), server.Close
}
diff --git a/api/schedule.go b/api/schedule.go
new file mode 100644
index 000000000..9452195b1
--- /dev/null
+++ b/api/schedule.go
@@ -0,0 +1,42 @@
+package api
+
+import (
+ "time"
+)
+
+type Timetable struct {
+ PerHour uint `json:"per-hour"`
+ HoursOfDay []uint `json:"hours-of-day"`
+ DaysOfWeek []string `json:"days-of-week"`
+}
+
+type Actor struct {
+ ID string `json:"id"`
+ Login string `json:"login"`
+ Name string `json:"name"`
+}
+
+type Schedule struct {
+ ID string `json:"id"`
+ ProjectSlug string `json:"project-slug"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Timetable Timetable `json:"timetable"`
+ Actor Actor `json:"actor"`
+ Parameters map[string]string `json:"parameters"`
+ CreatedAt time.Time `json:"created-at"`
+ UpdatedAt time.Time `json:"updated-at"`
+}
+
+type ScheduleInterface interface {
+ Schedules(vcs, org, project string) (*[]Schedule, error)
+ ScheduleByID(scheduleID string) (*Schedule, error)
+ ScheduleByName(vcs, org, project, name string) (*Schedule, error)
+ DeleteSchedule(scheduleID string) error
+ CreateSchedule(vcs, org, project, name, description string,
+ useSchedulingSystem bool, timetable Timetable,
+ parameters map[string]string) (*Schedule, error)
+ UpdateSchedule(scheduleID, name, description string,
+ useSchedulingSystem bool, timetable Timetable,
+ parameters map[string]string) (*Schedule, error)
+}
diff --git a/api/schedule_rest.go b/api/schedule_rest.go
new file mode 100644
index 000000000..efa8fac5c
--- /dev/null
+++ b/api/schedule_rest.go
@@ -0,0 +1,478 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "github.com/CircleCI-Public/circleci-cli/api/header"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/CircleCI-Public/circleci-cli/version"
+ "github.com/pkg/errors"
+)
+
+// Communicates with the CircleCI REST API to ask questions about
+// schedules. It satisfies api.ScheduleInterface.
+type ScheduleRestClient struct {
+ token string
+ server string
+ client *http.Client
+}
+
+type listSchedulesResponse struct {
+ Items []Schedule
+ NextPageToken *string `json:"next_page_token"`
+ client *ScheduleRestClient
+ params *listSchedulesParams
+}
+
+type listSchedulesParams struct {
+ PageToken *string
+}
+
+// Creates a new schedule in the supplied project.
+func (c *ScheduleRestClient) CreateSchedule(vcs, org, project, name, description string,
+ useSchedulingSystem bool, timetable Timetable, parameters map[string]string) (*Schedule, error) {
+
+ req, err := c.newCreateScheduleRequest(vcs, org, project, name, description, useSchedulingSystem, timetable, parameters)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := c.client.Do(req)
+
+ if err != nil {
+ return nil, err
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ defer resp.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != 201 {
+ var dest errorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ return nil, errors.New(*dest.Message)
+ }
+
+ var schedule Schedule
+ if err := json.Unmarshal(bodyBytes, &schedule); err != nil {
+ return nil, err
+ }
+
+ return &schedule, nil
+}
+
+// Updates an existing schedule.
+func (c *ScheduleRestClient) UpdateSchedule(scheduleID, name, description string,
+ useSchedulingSystem bool, timetable Timetable, parameters map[string]string) (*Schedule, error) {
+
+ req, err := c.newUpdateScheduleRequest(scheduleID, name, description, useSchedulingSystem, timetable, parameters)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := c.client.Do(req)
+
+ if err != nil {
+ return nil, err
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ defer resp.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != 200 {
+ var dest errorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ return nil, errors.New(*dest.Message)
+ }
+
+ var schedule Schedule
+ if err := json.Unmarshal(bodyBytes, &schedule); err != nil {
+ return nil, err
+ }
+
+ return &schedule, nil
+}
+
+// Deletes the schedule with the given ID.
+func (c *ScheduleRestClient) DeleteSchedule(scheduleID string) error {
+ req, err := c.newDeleteScheduleRequest(scheduleID)
+
+ if err != nil {
+ return err
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ defer resp.Body.Close()
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != 200 {
+ var dest errorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return err
+ }
+ return errors.New(*dest.Message)
+ }
+ return nil
+}
+
+// Returns all of the schedules for a given project. Note that
+// pagination is not currently supported - we get all pages of
+// schedules and return them all.
+func (c *ScheduleRestClient) Schedules(vcs, org, project string) (*[]Schedule, error) {
+ schedules, err := c.listAllSchedules(vcs, org, project, &listSchedulesParams{})
+ return &schedules, err
+}
+
+// Returns the schedule with the given ID.
+func (c *ScheduleRestClient) ScheduleByID(scheduleID string) (*Schedule, error) {
+ req, err := c.newGetScheduleRequest(scheduleID)
+
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ defer resp.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != 200 {
+ var dest errorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ return nil, errors.New(*dest.Message)
+ }
+
+ schedule := Schedule{}
+ if err := json.Unmarshal(bodyBytes, &schedule); err != nil {
+ return nil, err
+ }
+
+ return &schedule, err
+}
+
+// Finds a single schedule by its name and returns it.
+func (c *ScheduleRestClient) ScheduleByName(vcs, org, project, name string) (*Schedule, error) {
+ params := &listSchedulesParams{}
+ for {
+ resp, err := c.listSchedules(vcs, org, project, params)
+ if err != nil {
+ return nil, err
+ }
+ for _, schedule := range resp.Items {
+ if schedule.Name == name {
+ return &schedule, nil
+ }
+ }
+ if resp.NextPageToken == nil {
+ return nil, nil
+ }
+ params.PageToken = resp.NextPageToken
+ }
+}
+
+// Fetches all pages of the schedule list API and returns a single
+// list with all the schedules.
+func (c *ScheduleRestClient) listAllSchedules(vcs, org, project string, params *listSchedulesParams) (schedules []Schedule, err error) {
+ var resp *listSchedulesResponse
+ for {
+ resp, err = c.listSchedules(vcs, org, project, params)
+ if err != nil {
+ return nil, err
+ }
+
+ schedules = append(schedules, resp.Items...)
+
+ if resp.NextPageToken == nil {
+ break
+ }
+
+ params.PageToken = resp.NextPageToken
+ }
+ return schedules, nil
+}
+
+// Fetches and returns one page of schedules.
+func (c *ScheduleRestClient) listSchedules(vcs, org, project string, params *listSchedulesParams) (*listSchedulesResponse, error) {
+ req, err := c.newListSchedulesRequest(vcs, org, project, params)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ defer resp.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != 200 {
+ var dest errorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+
+ }
+ return nil, errors.New(*dest.Message)
+
+ }
+
+ dest := listSchedulesResponse{
+ client: c,
+ params: params,
+ }
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ return &dest, nil
+}
+
+// Builds a request to fetch a schedule by ID.
+func (c *ScheduleRestClient) newGetScheduleRequest(scheduleID string) (*http.Request, error) {
+ queryURL, err := url.Parse(c.server)
+ if err != nil {
+ return nil, err
+ }
+
+ queryURL, err = queryURL.Parse(fmt.Sprintf("schedule/%s", scheduleID))
+ if err != nil {
+ return nil, err
+ }
+
+ return c.newHTTPRequest("GET", queryURL.String(), nil)
+}
+
+// Builds a request to create a new schedule.
+func (c *ScheduleRestClient) newCreateScheduleRequest(vcs, org, project, name, description string,
+ useSchedulingSystem bool, timetable Timetable, parameters map[string]string) (*http.Request, error) {
+
+ var err error
+ queryURL, err := url.Parse(c.server)
+ if err != nil {
+ return nil, err
+ }
+ queryURL, err = queryURL.Parse(fmt.Sprintf("project/%s/%s/%s/schedule", vcs, org, project))
+ if err != nil {
+ return nil, err
+ }
+
+ actor := "current"
+ if useSchedulingSystem {
+ actor = "system"
+ }
+
+ var bodyReader io.Reader
+
+ var body = struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ AttributionActor string `json:"attribution-actor"`
+ Parameters map[string]string `json:"parameters"`
+ Timetable Timetable `json:"timetable"`
+ }{
+ Name: name,
+ Description: description,
+ AttributionActor: actor,
+ Parameters: parameters,
+ Timetable: timetable,
+ }
+ buf, err := json.Marshal(body)
+
+ if err != nil {
+ return nil, err
+ }
+
+ bodyReader = bytes.NewReader(buf)
+
+ return c.newHTTPRequest("POST", queryURL.String(), bodyReader)
+}
+
+// Builds a request to update an existing schedule.
+func (c *ScheduleRestClient) newUpdateScheduleRequest(scheduleID, name, description string,
+ useSchedulingSystem bool, timetable Timetable, parameters map[string]string) (*http.Request, error) {
+
+ var err error
+ queryURL, err := url.Parse(c.server)
+ if err != nil {
+ return nil, err
+ }
+ queryURL, err = queryURL.Parse(fmt.Sprintf("schedule/%s", scheduleID))
+ if err != nil {
+ return nil, err
+ }
+
+ actor := "current"
+ if useSchedulingSystem {
+ actor = "system"
+ }
+
+ var bodyReader io.Reader
+
+ var body = struct {
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ AttributionActor string `json:"attribution-actor,omitempty"`
+ Parameters map[string]string `json:"parameters,omitempty"`
+ Timetable Timetable `json:"timetable,omitempty"`
+ }{
+ Name: name,
+ Description: description,
+ AttributionActor: actor,
+ Parameters: parameters,
+ Timetable: timetable,
+ }
+ buf, err := json.Marshal(body)
+
+ if err != nil {
+ return nil, err
+ }
+
+ bodyReader = bytes.NewReader(buf)
+
+ return c.newHTTPRequest("PATCH", queryURL.String(), bodyReader)
+}
+
+// Builds a request to delete an existing schedule.
+func (c *ScheduleRestClient) newDeleteScheduleRequest(scheduleID string) (*http.Request, error) {
+ var err error
+ queryURL, err := url.Parse(c.server)
+ if err != nil {
+ return nil, err
+ }
+ queryURL, err = queryURL.Parse(fmt.Sprintf("schedule/%s", scheduleID))
+ if err != nil {
+ return nil, err
+ }
+ return c.newHTTPRequest("DELETE", queryURL.String(), nil)
+}
+
+// Builds a request to list schedules according to params.
+func (c *ScheduleRestClient) newListSchedulesRequest(vcs, org, project string, params *listSchedulesParams) (*http.Request, error) {
+ var err error
+ queryURL, err := url.Parse(c.server)
+ if err != nil {
+ return nil, err
+ }
+ queryURL, err = queryURL.Parse(fmt.Sprintf("project/%s/%s/%s/schedule", vcs, org, project))
+ if err != nil {
+ return nil, err
+ }
+
+ urlParams := url.Values{}
+ if params.PageToken != nil {
+ urlParams.Add("page-token", *params.PageToken)
+ }
+
+ queryURL.RawQuery = urlParams.Encode()
+
+ return c.newHTTPRequest("GET", queryURL.String(), nil)
+}
+
+// Returns a new blank API request with boilerplate headers.
+func (c *ScheduleRestClient) newHTTPRequest(method, url string, body io.Reader) (*http.Request, error) {
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return nil, err
+ }
+ if c.token != "" {
+ req.Header.Add("circle-token", c.token)
+ }
+ req.Header.Add("Accept", "application/json")
+ req.Header.Add("Content-Type", "application/json")
+ req.Header.Add("User-Agent", version.UserAgent())
+ commandStr := header.GetCommandStr()
+ if commandStr != "" {
+ req.Header.Add("Circleci-Cli-Command", commandStr)
+ }
+ return req, nil
+}
+
+// Verifies that the REST API exists and has the necessary endpoints
+// to interact with schedules.
+func (c *ScheduleRestClient) EnsureExists() error {
+ queryURL, err := url.Parse(c.server)
+ if err != nil {
+ return err
+ }
+ queryURL, err = queryURL.Parse("openapi.json")
+ if err != nil {
+ return err
+ }
+ req, err := c.newHTTPRequest("GET", queryURL.String(), nil)
+ if err != nil {
+ return err
+ }
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != 200 {
+ return errors.New("API v2 test request failed.")
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ defer resp.Body.Close()
+ if err != nil {
+ return err
+ }
+ var respBody struct {
+ Paths struct {
+ ScheduleEndpoint interface{} `json:"/schedule"`
+ }
+ }
+ if err := json.Unmarshal(bodyBytes, &respBody); err != nil {
+ return err
+ }
+
+ if respBody.Paths.ScheduleEndpoint == nil {
+ return errors.New("No schedule endpoint exists")
+ }
+
+ return nil
+}
+
+// Returns a new client satisfying the api.ScheduleInterface interface
+// via the REST API.
+func NewScheduleRestClient(config settings.Config) (*ScheduleRestClient, error) {
+ serverURL, err := config.ServerURL()
+ if err != nil {
+ return nil, err
+ }
+
+ client := &ScheduleRestClient{
+ token: config.Token,
+ server: serverURL.String(),
+ client: config.HTTPClient,
+ }
+
+ return client, nil
+}
diff --git a/api/schedule_test.go b/api/schedule_test.go
new file mode 100644
index 000000000..c046b4321
--- /dev/null
+++ b/api/schedule_test.go
@@ -0,0 +1,206 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "gotest.tools/v3/assert"
+
+ "github.com/CircleCI-Public/circleci-cli/mock"
+)
+
+// Returns a static mock schedule.
+func mockSchedule() Schedule {
+ return Schedule{
+ ID: "07f08dea-de06-48d4-9b47-9639229b7d24",
+ ProjectSlug: "github/test-org/test-project",
+ Name: "test-schedule",
+ Description: "A test schedule",
+ Timetable: Timetable{
+ PerHour: 1,
+ HoursOfDay: []uint{4, 8, 15, 16, 23},
+ DaysOfWeek: []string{"MON", "THU", "SAT"},
+ },
+ Actor: Actor{
+ ID: "807d18e2-a8e2-4b1e-8a54-18da6e0cc478",
+ Login: "test-actor",
+ Name: "T. Actor",
+ },
+ Parameters: map[string]string{
+ "test": "parameter",
+ },
+ }
+}
+
+// Returns the string representation of the static mock schedule.
+func mockScheduleString() string {
+ schedule := mockSchedule()
+ rv, err := json.Marshal(schedule)
+ if err != nil {
+ panic("Failed to serialise mock schedule")
+ }
+ return string(rv)
+}
+
+// Takes a number of schedules and formats them as if they were
+// returned as a page of our paginated API. No next page tokens
+// included at this point.
+func formatListResponse(schedules []Schedule) string {
+ schedule := mockSchedule()
+ var resp = struct {
+ Items []Schedule `json:"items"`
+ NextPageToken string `json:"next_page_token,omitempty"`
+ }{
+ Items: []Schedule{schedule},
+ }
+ serialized, err := json.Marshal(resp)
+ if err != nil {
+ panic("Failed to serialise mock list response")
+ }
+ return string(serialized)
+}
+
+func TestSchedules(t *testing.T) {
+ mockFn := func(r *http.Request) (*http.Response, error) {
+ if r.URL.String() != "https://circleci.com/api/v2/project/github/test-org/test-project/schedule" {
+ panic(fmt.Sprintf("unexpected url: %s", r.URL.String()))
+ }
+ if r.Method != http.MethodGet {
+ panic(fmt.Sprintf("unexpected method: %s", r.Method))
+ }
+ return mock.NewHTTPResponse(200, formatListResponse([]Schedule{mockSchedule()})), nil
+ }
+ httpClient := mock.NewHTTPClient(mockFn)
+ restClient := ScheduleRestClient{
+ server: "https://circleci.com/api/v2/",
+ client: httpClient,
+ }
+
+ t.Run("Get all schedules for a project", func(t *testing.T) {
+ schedules, err := restClient.Schedules("github", "test-org", "test-project")
+ assert.NilError(t, err)
+ assert.DeepEqual(t, mockSchedule(), (*schedules)[0])
+ })
+}
+
+func TestScheduleByID(t *testing.T) {
+ mockFn := func(r *http.Request) (*http.Response, error) {
+ if r.URL.String() != "https://circleci.com/api/v2/schedule/07f08dea-de06-48d4-9b47-9639229b7d24" {
+ panic(fmt.Sprintf("unexpected url: %s", r.URL.String()))
+ }
+ if r.Method != http.MethodGet {
+ panic(fmt.Sprintf("unexpected method: %s", r.Method))
+ }
+ return mock.NewHTTPResponse(200, mockScheduleString()), nil
+ }
+ httpClient := mock.NewHTTPClient(mockFn)
+ restClient := ScheduleRestClient{
+ server: "https://circleci.com/api/v2/",
+ client: httpClient,
+ }
+
+ t.Run("Get a schedule by ID", func(t *testing.T) {
+ schedule := mockSchedule()
+ gotten, err := restClient.ScheduleByID(schedule.ID)
+ assert.NilError(t, err)
+ assert.DeepEqual(t, schedule, *gotten)
+ })
+}
+
+func TestScheduleByName(t *testing.T) {
+ mockFn := func(r *http.Request) (*http.Response, error) {
+ if r.URL.String() != "https://circleci.com/api/v2/project/github/test-org/test-project/schedule" {
+ panic(fmt.Sprintf("unexpected url: %s", r.URL.String()))
+ }
+ if r.Method != http.MethodGet {
+ panic(fmt.Sprintf("unexpected method: %s", r.Method))
+ }
+ return mock.NewHTTPResponse(200, formatListResponse([]Schedule{mockSchedule()})), nil
+ }
+ httpClient := mock.NewHTTPClient(mockFn)
+ restClient := ScheduleRestClient{
+ server: "https://circleci.com/api/v2/",
+ client: httpClient,
+ }
+
+ t.Run("Get a schedule by name", func(t *testing.T) {
+ schedule := mockSchedule()
+ gotten, err := restClient.ScheduleByName("github", "test-org", "test-project", schedule.Name)
+ assert.NilError(t, err)
+ assert.DeepEqual(t, schedule, *gotten)
+ })
+}
+
+func TestDeleteSchedule(t *testing.T) {
+ mockFn := func(r *http.Request) (*http.Response, error) {
+ if r.URL.String() != "https://circleci.com/api/v2/schedule/07f08dea-de06-48d4-9b47-9639229b7d24" {
+ panic(fmt.Sprintf("unexpected url: %s", r.URL.String()))
+ }
+ if r.Method != "DELETE" {
+ panic(fmt.Sprintf("unexpected method: %s", r.Method))
+ }
+ return mock.NewHTTPResponse(200, "{\"message\": \"okay\"}"), nil
+ }
+ httpClient := mock.NewHTTPClient(mockFn)
+ restClient := ScheduleRestClient{
+ server: "https://circleci.com/api/v2/",
+ client: httpClient,
+ }
+
+ t.Run("Delete a schedule", func(t *testing.T) {
+ err := restClient.DeleteSchedule("07f08dea-de06-48d4-9b47-9639229b7d24")
+ assert.NilError(t, err)
+ })
+}
+
+func TestCreateSchedule(t *testing.T) {
+ mockFn := func(r *http.Request) (*http.Response, error) {
+ if r.URL.String() != "https://circleci.com/api/v2/project/github/test-org/test-project/schedule" {
+ panic(fmt.Sprintf("unexpected url: %s", r.URL.String()))
+ }
+ if r.Method != "POST" {
+ panic(fmt.Sprintf("unexpected method: %s", r.Method))
+ }
+ return mock.NewHTTPResponse(201, mockScheduleString()), nil
+ }
+ httpClient := mock.NewHTTPClient(mockFn)
+ restClient := ScheduleRestClient{
+ server: "https://circleci.com/api/v2/",
+ client: httpClient,
+ }
+
+ t.Run("Create a schedule", func(t *testing.T) {
+ schedule := mockSchedule()
+ created, err := restClient.CreateSchedule("github", "test-org", "test-project",
+ schedule.Name, schedule.Description, true, schedule.Timetable, schedule.Parameters)
+ assert.NilError(t, err)
+ assert.DeepEqual(t, schedule, *created)
+ })
+}
+
+func TestUpdateSchedule(t *testing.T) {
+ mockFn := func(r *http.Request) (*http.Response, error) {
+ if r.URL.String() != "https://circleci.com/api/v2/schedule/07f08dea-de06-48d4-9b47-9639229b7d24" {
+ panic(fmt.Sprintf("unexpected url: %s", r.URL.String()))
+ }
+ if r.Method != "PATCH" {
+ panic(fmt.Sprintf("unexpected method: %s", r.Method))
+ }
+ return mock.NewHTTPResponse(200, mockScheduleString()), nil
+ }
+ httpClient := mock.NewHTTPClient(mockFn)
+ restClient := ScheduleRestClient{
+ server: "https://circleci.com/api/v2/",
+ client: httpClient,
+ }
+
+ t.Run("Update a schedule", func(t *testing.T) {
+ schedule := mockSchedule()
+ updated, err := restClient.UpdateSchedule(schedule.ID, schedule.Name, schedule.Description,
+ false, schedule.Timetable, schedule.Parameters)
+ assert.NilError(t, err)
+ assert.DeepEqual(t, schedule, *updated)
+ })
+}
diff --git a/bin/.gitkeep b/bin/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/bin/darwin_386/packr2 b/bin/darwin_386/packr2
deleted file mode 100755
index 6f4c7fb33..000000000
Binary files a/bin/darwin_386/packr2 and /dev/null differ
diff --git a/bin/darwin_amd64/packr2 b/bin/darwin_amd64/packr2
deleted file mode 100755
index 303ccb616..000000000
Binary files a/bin/darwin_amd64/packr2 and /dev/null differ
diff --git a/bin/linux_386/packr2 b/bin/linux_386/packr2
deleted file mode 100755
index 22f98b9ee..000000000
Binary files a/bin/linux_386/packr2 and /dev/null differ
diff --git a/bin/linux_amd64/packr2 b/bin/linux_amd64/packr2
deleted file mode 100755
index 451534f96..000000000
Binary files a/bin/linux_amd64/packr2 and /dev/null differ
diff --git a/chocolatey/circleci-cli/circleci-cli.nuspec b/chocolatey/circleci-cli/circleci-cli.nuspec
index c8b7dbad4..c5c977d0c 100644
--- a/chocolatey/circleci-cli/circleci-cli.nuspec
+++ b/chocolatey/circleci-cli/circleci-cli.nuspec
@@ -14,7 +14,7 @@
circleci-cli (Install)
CircleCI
https://circleci.com/docs/2.0/local-cli/
- https://github.com/CircleCI-Public/circleci-cli/raw/master/chocolatey/icons/circleci-128x.png
+ https://github.com/CircleCI-Public/circleci-cli/raw/main/chocolatey/icons/circleci-128x.png
https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/master/LICENSE
true
https://github.com/CircleCI-Public/circleci-cli
diff --git a/clitest/clitest.go b/clitest/clitest.go
index 19e1c4702..6a0010d5c 100644
--- a/clitest/clitest.go
+++ b/clitest/clitest.go
@@ -5,7 +5,6 @@ import (
"bytes"
"fmt"
"io"
- "io/ioutil"
"net/http"
"os"
"path/filepath"
@@ -50,7 +49,7 @@ func (tempSettings TempSettings) AssertConfigRereadMatches(contents string) {
file, err := os.Open(tempSettings.Config.Path)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
- reread, err := ioutil.ReadAll(file)
+ reread, err := io.ReadAll(file)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
gomega.Expect(string(reread)).To(gomega.ContainSubstring(contents))
}
@@ -61,7 +60,7 @@ func WithTempSettings() *TempSettings {
tempSettings := &TempSettings{}
- tempSettings.Home, err = ioutil.TempDir("", "circleci-cli-test-")
+ tempSettings.Home, err = os.MkdirTemp("", "circleci-cli-test-")
gomega.Expect(err).ToNot(gomega.HaveOccurred())
settingsPath := filepath.Join(tempSettings.Home, ".circleci")
@@ -89,6 +88,30 @@ type MockRequestResponse struct {
ErrorResponse string
}
+func (tempSettings *TempSettings) AppendRESTPostHandler(combineHandlers ...MockRequestResponse) {
+ for _, handler := range combineHandlers {
+ responseBody := handler.Response
+ if handler.ErrorResponse != "" {
+ responseBody = handler.ErrorResponse
+ }
+
+ tempSettings.TestServer.AppendHandlers(
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", "/api/v2/context"),
+ ghttp.VerifyContentType("application/json"),
+ func(w http.ResponseWriter, req *http.Request) {
+ body, err := io.ReadAll(req.Body)
+ gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
+ err = req.Body.Close()
+ gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
+ gomega.Expect(handler.Request).Should(gomega.MatchJSON(body), "JSON Mismatch")
+ },
+ ghttp.RespondWith(handler.Status, responseBody),
+ ),
+ )
+ }
+}
+
// AppendPostHandler stubs out the provided MockRequestResponse.
// When authToken is an empty string no token validation is performed.
func (tempSettings *TempSettings) AppendPostHandler(authToken string, combineHandlers ...MockRequestResponse) {
@@ -107,7 +130,7 @@ func (tempSettings *TempSettings) AppendPostHandler(authToken string, combineHan
// VerifyContentType("application/json") check
// that fails with "application/json; charset=utf-8"
func(w http.ResponseWriter, req *http.Request) {
- body, err := ioutil.ReadAll(req.Body)
+ body, err := io.ReadAll(req.Body)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
err = req.Body.Close()
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
@@ -128,7 +151,7 @@ func (tempSettings *TempSettings) AppendPostHandler(authToken string, combineHan
// VerifyContentType("application/json") check
// that fails with "application/json; charset=utf-8"
func(w http.ResponseWriter, req *http.Request) {
- body, err := ioutil.ReadAll(req.Body)
+ body, err := io.ReadAll(req.Body)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
err = req.Body.Close()
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
diff --git a/cmd/admin_test.go b/cmd/admin_test.go
index ae8764f06..40f330e63 100644
--- a/cmd/admin_test.go
+++ b/cmd/admin_test.go
@@ -181,7 +181,7 @@ var _ = Describe("Namespace integration tests", func() {
}`
expectedListOrbsRequest := `{
- "query": "\nquery namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType) {\n\tregistryNamespace(name: $namespace) {\n\t\tname\n id\n\t\torbs(first: 20, after: $after, view: $view) {\n\t\t\tedges {\n\t\t\t\tcursor\n\t\t\t\tnode {\n\t\t\t\t\tversions {\n\t\t\t\t\t\tsource\n\t\t\t\t\t\tversion\n\t\t\t\t\t}\n\t\t\t\t\tname\n\t statistics {\n\t\t last30DaysBuildCount,\n\t\t last30DaysProjectCount,\n\t\t last30DaysOrganizationCount\n\t }\n\t\t\t\t}\n\t\t\t}\n\t\t\ttotalCount\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t}\n\t}\n}\n",
+ "query": "\nquery namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType) {\n\tregistryNamespace(name: $namespace) {\n\t\tname\n id\n\t\torbs(first: 20, after: $after, view: $view) {\n\t\t\tedges {\n\t\t\t\tcursor\n\t\t\t\tnode {\n\t\t\t\t\tversions { version\n\t\t\t\t\t}\n\t\t\t\t\tname\n\t statistics {\n\t\t last30DaysBuildCount,\n\t\t last30DaysProjectCount,\n\t\t last30DaysOrganizationCount\n\t }\n\t\t\t\t}\n\t\t\t}\n\t\t\ttotalCount\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t}\n\t}\n}\n",
"variables": {
"after": "",
"namespace": "foo-ns",
@@ -236,7 +236,7 @@ var _ = Describe("Namespace integration tests", func() {
}`
expectedListOrbsRequest := `{
- "query": "\nquery namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType) {\n\tregistryNamespace(name: $namespace) {\n\t\tname\n id\n\t\torbs(first: 20, after: $after, view: $view) {\n\t\t\tedges {\n\t\t\t\tcursor\n\t\t\t\tnode {\n\t\t\t\t\tversions {\n\t\t\t\t\t\tsource\n\t\t\t\t\t\tversion\n\t\t\t\t\t}\n\t\t\t\t\tname\n\t statistics {\n\t\t last30DaysBuildCount,\n\t\t last30DaysProjectCount,\n\t\t last30DaysOrganizationCount\n\t }\n\t\t\t\t}\n\t\t\t}\n\t\t\ttotalCount\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t}\n\t}\n}\n",
+ "query": "\nquery namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType) {\n\tregistryNamespace(name: $namespace) {\n\t\tname\n id\n\t\torbs(first: 20, after: $after, view: $view) {\n\t\t\tedges {\n\t\t\t\tcursor\n\t\t\t\tnode {\n\t\t\t\t\tversions { version\n\t\t\t\t\t}\n\t\t\t\t\tname\n\t statistics {\n\t\t last30DaysBuildCount,\n\t\t last30DaysProjectCount,\n\t\t last30DaysOrganizationCount\n\t }\n\t\t\t\t}\n\t\t\t}\n\t\t\ttotalCount\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t}\n\t}\n}\n",
"variables": {
"after": "",
"namespace": "foo-ns",
@@ -251,7 +251,7 @@ var _ = Describe("Namespace integration tests", func() {
}
}`
- expectedDeleteNamespacerequest := `{
+ expectedDeleteNamespaceRequest := `{
"query": "\nmutation($id: UUID!) {\n deleteNamespaceAndRelatedOrbs(namespaceId: $id) {\n deleted\n errors {\n type\n message\n }\n }\n}\n",
"variables": {
"id": "f13a9e13-538c-435c-8f61-78596661acd6"
@@ -270,7 +270,7 @@ var _ = Describe("Namespace integration tests", func() {
tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{
Status: http.StatusOK,
- Request: expectedDeleteNamespacerequest,
+ Request: expectedDeleteNamespaceRequest,
Response: gqlDeleteNamespaceResponse})
session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
@@ -310,7 +310,7 @@ var _ = Describe("Namespace integration tests", func() {
}`
expectedListOrbsRequest := `{
- "query": "\nquery namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType) {\n\tregistryNamespace(name: $namespace) {\n\t\tname\n id\n\t\torbs(first: 20, after: $after, view: $view) {\n\t\t\tedges {\n\t\t\t\tcursor\n\t\t\t\tnode {\n\t\t\t\t\tversions {\n\t\t\t\t\t\tsource\n\t\t\t\t\t\tversion\n\t\t\t\t\t}\n\t\t\t\t\tname\n\t statistics {\n\t\t last30DaysBuildCount,\n\t\t last30DaysProjectCount,\n\t\t last30DaysOrganizationCount\n\t }\n\t\t\t\t}\n\t\t\t}\n\t\t\ttotalCount\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t}\n\t}\n}\n",
+ "query": "\nquery namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType) {\n\tregistryNamespace(name: $namespace) {\n\t\tname\n id\n\t\torbs(first: 20, after: $after, view: $view) {\n\t\t\tedges {\n\t\t\t\tcursor\n\t\t\t\tnode {\n\t\t\t\t\tversions { version\n\t\t\t\t\t}\n\t\t\t\t\tname\n\t statistics {\n\t\t last30DaysBuildCount,\n\t\t last30DaysProjectCount,\n\t\t last30DaysOrganizationCount\n\t }\n\t\t\t\t}\n\t\t\t}\n\t\t\ttotalCount\n\t\t\tpageInfo {\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t}\n\t}\n}\n",
"variables": {
"after": "",
"namespace": "foo-ns",
@@ -324,7 +324,7 @@ var _ = Describe("Namespace integration tests", func() {
}
}`
- expectedDeleteNamespacerequest := `{
+ expectedDeleteNamespaceRequest := `{
"query": "\nmutation($id: UUID!) {\n deleteNamespaceAndRelatedOrbs(namespaceId: $id) {\n deleted\n errors {\n type\n message\n }\n }\n}\n",
"variables": {
"id": "f13a9e13-538c-435c-8f61-78596661acd6"
@@ -343,7 +343,7 @@ var _ = Describe("Namespace integration tests", func() {
tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{
Status: http.StatusOK,
- Request: expectedDeleteNamespacerequest,
+ Request: expectedDeleteNamespaceRequest,
Response: gqlDeleteNamespaceResponse})
session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
diff --git a/cmd/build.go b/cmd/build.go
index 96fa77510..9d1d773f2 100644
--- a/cmd/build.go
+++ b/cmd/build.go
@@ -7,16 +7,25 @@ import (
)
func newLocalExecuteCommand(config *settings.Config) *cobra.Command {
+ var args []string
buildCommand := &cobra.Command{
- Use: "execute",
+ Use: "execute ",
Short: "Run a job in a container on the local machine",
+ PreRunE: func(cmd *cobra.Command, _args []string) error {
+ args = _args
+ return nil
+ },
RunE: func(cmd *cobra.Command, _ []string) error {
- return local.Execute(cmd.Flags(), config)
+ return local.Execute(cmd.Flags(), config, args)
},
+ Args: cobra.MinimumNArgs(1),
}
local.AddFlagsForDocumentation(buildCommand.Flags())
+ buildAgentVersionUsage := `The version of the build agent image you want to use. This can be configured by writing in $HOME/.circleci/build_agent_settings.json: '{"LatestSha256":""}'`
+ buildCommand.Flags().String("build-agent-version", "", buildAgentVersionUsage)
buildCommand.Flags().StringP("org-slug", "o", "", "organization slug (for example: github/example-org), used when a config depends on private orbs belonging to that org")
+ buildCommand.Flags().String("org-id", "", "organization id, used when a config depends on private orbs belonging to that org")
return buildCommand
}
diff --git a/cmd/check_test.go b/cmd/check_test.go
index 1c4ad928b..5b9a419a4 100644
--- a/cmd/check_test.go
+++ b/cmd/check_test.go
@@ -96,7 +96,7 @@ var _ = Describe("Check", func() {
tempSettings.TestServer.AppendHandlers(
ghttp.CombineHandlers(
- ghttp.VerifyRequest("GET", "/repos/CircleCI-Public/circleci-cli/releases"),
+ ghttp.VerifyRequest(http.MethodGet, "/repos/CircleCI-Public/circleci-cli/releases"),
ghttp.RespondWith(http.StatusOK, response),
),
)
diff --git a/cmd/config.go b/cmd/config.go
index 7216e6b25..d2afbef52 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -2,40 +2,27 @@ package cmd
import (
"fmt"
- "io/ioutil"
- "github.com/CircleCI-Public/circleci-cli/api"
- "github.com/CircleCI-Public/circleci-cli/api/graphql"
+ "github.com/CircleCI-Public/circleci-cli/config"
"github.com/CircleCI-Public/circleci-cli/filetree"
- "github.com/CircleCI-Public/circleci-cli/local"
- "github.com/CircleCI-Public/circleci-cli/pipeline"
"github.com/CircleCI-Public/circleci-cli/proxy"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/pkg/errors"
"github.com/spf13/cobra"
- "github.com/spf13/pflag"
"gopkg.in/yaml.v3"
)
-type configOptions struct {
- cfg *settings.Config
- cl *graphql.Client
- args []string
-}
-
// Path to the config.yml file to operate on.
// Used to for compatibility with `circleci config validate --path`
var configPath string
+var ignoreDeprecatedImages bool // should we ignore deprecated images warning
+var verboseOutput bool // Enable extra debugging output
var configAnnotations = map[string]string{
"": "The path to your config (use \"-\" for STDIN)",
}
-func newConfigCommand(config *settings.Config) *cobra.Command {
- opts := configOptions{
- cfg: config,
- }
-
+func newConfigCommand(globalConfig *settings.Config) *cobra.Command {
configCmd := &cobra.Command{
Use: "config",
Short: "Operate on build config files",
@@ -44,11 +31,8 @@ func newConfigCommand(config *settings.Config) *cobra.Command {
packCommand := &cobra.Command{
Use: "pack ",
Short: "Pack up your CircleCI configuration into a single file.",
- PreRun: func(cmd *cobra.Command, args []string) {
- opts.args = args
- },
- RunE: func(_ *cobra.Command, _ []string) error {
- return packConfig(opts)
+ RunE: func(_ *cobra.Command, args []string) error {
+ return packConfig(args)
},
Args: cobra.ExactArgs(1),
Annotations: make(map[string]string),
@@ -59,48 +43,76 @@ func newConfigCommand(config *settings.Config) *cobra.Command {
Use: "validate ",
Aliases: []string{"check"},
Short: "Check that the config file is well formed.",
- PreRun: func(cmd *cobra.Command, args []string) {
- opts.args = args
- opts.cl = graphql.NewClient(config.HTTPClient, config.Host, config.Endpoint, config.Token, config.Debug)
- },
- RunE: func(cmd *cobra.Command, _ []string) error {
- return validateConfig(opts, cmd.Flags())
+ RunE: func(cmd *cobra.Command, args []string) error {
+ compiler := config.New(globalConfig)
+ orgID, _ := cmd.Flags().GetString("org-id")
+ orgSlug, _ := cmd.Flags().GetString("org-slug")
+ path := config.DefaultConfigPath
+ if configPath != "" {
+ path = configPath
+ }
+ if len(args) == 1 {
+ path = args[0]
+ }
+ return compiler.ValidateConfig(config.ValidateConfigOpts{
+ ConfigPath: path,
+ OrgID: orgID,
+ OrgSlug: orgSlug,
+ IgnoreDeprecatedImages: ignoreDeprecatedImages,
+ VerboseOutput: verboseOutput,
+ })
},
Args: cobra.MaximumNArgs(1),
Annotations: make(map[string]string),
}
validateCommand.Annotations[""] = configAnnotations[""]
validateCommand.PersistentFlags().StringVarP(&configPath, "config", "c", ".circleci/config.yml", "path to config file")
+ validateCommand.PersistentFlags().BoolVarP(&verboseOutput, "verbose", "v", false, "Enable verbose output")
+ validateCommand.PersistentFlags().BoolVar(&ignoreDeprecatedImages, "ignore-deprecated-images", false, "ignores the deprecated images error")
+
if err := validateCommand.PersistentFlags().MarkHidden("config"); err != nil {
panic(err)
}
validateCommand.Flags().StringP("org-slug", "o", "", "organization slug (for example: github/example-org), used when a config depends on private orbs belonging to that org")
+ validateCommand.Flags().String("org-id", "", "organization id used when a config depends on private orbs belonging to that org")
processCommand := &cobra.Command{
Use: "process ",
Short: "Validate config and display expanded configuration.",
- PreRun: func(cmd *cobra.Command, args []string) {
- opts.args = args
- opts.cl = graphql.NewClient(config.HTTPClient, config.Host, config.Endpoint, config.Token, config.Debug)
- },
- RunE: func(cmd *cobra.Command, _ []string) error {
- return processConfig(opts, cmd.Flags())
+ RunE: func(cmd *cobra.Command, args []string) error {
+ compiler := config.New(globalConfig)
+ pipelineParamsFilePath, _ := cmd.Flags().GetString("pipeline-parameters")
+ orgID, _ := cmd.Flags().GetString("org-id")
+ orgSlug, _ := cmd.Flags().GetString("org-slug")
+ path := config.DefaultConfigPath
+ if configPath != "" {
+ path = configPath
+ }
+ if len(args) == 1 {
+ path = args[0]
+ }
+ return compiler.ProcessConfig(config.ProcessConfigOpts{
+ ConfigPath: path,
+ OrgID: orgID,
+ OrgSlug: orgSlug,
+ PipelineParamsFilePath: pipelineParamsFilePath,
+ VerboseOutput: verboseOutput,
+ })
},
Args: cobra.ExactArgs(1),
Annotations: make(map[string]string),
}
processCommand.Annotations[""] = configAnnotations[""]
processCommand.Flags().StringP("org-slug", "o", "", "organization slug (for example: github/example-org), used when a config depends on private orbs belonging to that org")
+ processCommand.Flags().String("org-id", "", "organization id used when a config depends on private orbs belonging to that org")
processCommand.Flags().StringP("pipeline-parameters", "", "", "YAML/JSON map of pipeline parameters, accepts either YAML/JSON directly or file path (for example: my-params.yml)")
+ processCommand.PersistentFlags().BoolVar(&verboseOutput, "verbose", false, "adds verbose output to the command")
migrateCommand := &cobra.Command{
Use: "migrate",
Short: "Migrate a pre-release 2.0 config to the official release version",
- PreRun: func(cmd *cobra.Command, args []string) {
- opts.args = args
- },
- RunE: func(_ *cobra.Command, _ []string) error {
- return migrateConfig(opts)
+ RunE: func(_ *cobra.Command, args []string) error {
+ return migrateConfig(args)
},
Hidden: true,
DisableFlagParsing: true,
@@ -117,66 +129,8 @@ func newConfigCommand(config *settings.Config) *cobra.Command {
return configCmd
}
-// The arg is actually optional, in order to support compatibility with the --path flag.
-func validateConfig(opts configOptions, flags *pflag.FlagSet) error {
- path := local.DefaultConfigPath
- // First, set the path to configPath set by --path flag for compatibility
- if configPath != "" {
- path = configPath
- }
-
- // Then, if an arg is passed in, choose that instead
- if len(opts.args) == 1 {
- path = opts.args[0]
- }
-
- orgSlug, _ := flags.GetString("org-slug")
-
- _, err := api.ConfigQuery(opts.cl, path, orgSlug, nil, pipeline.LocalPipelineValues())
- if err != nil {
- return err
- }
-
- if path == "-" {
- fmt.Printf("Config input is valid.\n")
- } else {
- fmt.Printf("Config file at %s is valid.\n", path)
- }
-
- return nil
-}
-
-func processConfig(opts configOptions, flags *pflag.FlagSet) error {
- orgSlug, _ := flags.GetString("org-slug")
- paramsYaml, _ := flags.GetString("pipeline-parameters")
-
- var params pipeline.Parameters
-
- if len(paramsYaml) > 0 {
- // The 'src' value can be a filepath, or a yaml string. If the file cannot be read sucessfully,
- // proceed with the assumption that the value is already valid yaml.
- raw, err := ioutil.ReadFile(paramsYaml)
- if err != nil {
- raw = []byte(paramsYaml)
- }
-
- err = yaml.Unmarshal(raw, ¶ms)
- if err != nil {
- return fmt.Errorf("invalid 'pipeline-parameters' provided: %s", err.Error())
- }
- }
-
- response, err := api.ConfigQuery(opts.cl, opts.args[0], orgSlug, params, pipeline.LocalPipelineValues())
- if err != nil {
- return err
- }
-
- fmt.Print(response.OutputYaml)
- return nil
-}
-
-func packConfig(opts configOptions) error {
- tree, err := filetree.NewTree(opts.args[0])
+func packConfig(args []string) error {
+ tree, err := filetree.NewTree(args[0])
if err != nil {
return errors.Wrap(err, "An error occurred trying to build the tree")
}
@@ -189,6 +143,6 @@ func packConfig(opts configOptions) error {
return nil
}
-func migrateConfig(opts configOptions) error {
- return proxy.Exec([]string{"config", "migrate"}, opts.args)
+func migrateConfig(args []string) error {
+ return proxy.Exec([]string{"config", "migrate"}, args)
}
diff --git a/cmd/config_test.go b/cmd/config_test.go
index 7363145c4..d0ed9016d 100644
--- a/cmd/config_test.go
+++ b/cmd/config_test.go
@@ -1,20 +1,13 @@
package cmd_test
import (
- "encoding/json"
"fmt"
- "io"
- "net/http"
"os/exec"
"path/filepath"
- "time"
- "github.com/CircleCI-Public/circleci-cli/api/graphql"
"github.com/CircleCI-Public/circleci-cli/clitest"
- "github.com/CircleCI-Public/circleci-cli/pipeline"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
- "github.com/onsi/gomega/gbytes"
"github.com/onsi/gomega/gexec"
"gotest.tools/v3/golden"
)
@@ -25,7 +18,6 @@ var _ = Describe("Config", func() {
command *exec.Cmd
results []byte
tempSettings *clitest.TempSettings
- token string = "testtoken"
)
BeforeEach(func() {
@@ -95,6 +87,112 @@ var _ = Describe("Config", func() {
})
})
+ It("packs all YAML contents as expected", func() {
+ command = exec.Command(pathCLI,
+ "config", "pack",
+ "--skip-update-check",
+ "testdata/hugo-pack/.circleci")
+ results = golden.Get(GinkgoT(), filepath.FromSlash("hugo-pack/result.yml"))
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ session.Wait()
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session.Err.Contents()).Should(BeEmpty())
+ Eventually(session.Out.Contents()).Should(MatchYAML(results))
+ Eventually(session).Should(gexec.Exit(0))
+ })
+
+ It("given a .circleci folder with config.yml and local orb, packs all YAML contents as expected", func() {
+ command = exec.Command(pathCLI,
+ "config", "pack",
+ "--skip-update-check",
+ "testdata/hugo-pack/.circleci")
+ results = golden.Get(GinkgoT(), filepath.FromSlash("hugo-pack/result.yml"))
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ session.Wait()
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session.Err.Contents()).Should(BeEmpty())
+ Eventually(session.Out.Contents()).Should(MatchYAML(results))
+ Eventually(session).Should(gexec.Exit(0))
+ })
+
+ It("given a local orbs folder with mixed inline and local commands, jobs, etc, packs all YAML contents as expected", func() {
+ var path string = "nested-orbs-and-local-commands-etc"
+ command = exec.Command(pathCLI,
+ "config", "pack",
+ "--skip-update-check",
+ filepath.Join("testdata", path, "test"))
+ results = golden.Get(GinkgoT(), filepath.FromSlash(fmt.Sprintf("%s/result.yml", path)))
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ session.Wait()
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session.Err.Contents()).Should(BeEmpty())
+ Eventually(session.Out.Contents()).Should(MatchYAML(results))
+ Eventually(session).Should(gexec.Exit(0))
+ })
+
+ It("returns an error when validating a config", func() {
+ var path string = "nested-orbs-and-local-commands-etc"
+ command = exec.Command(pathCLI,
+ "config", "pack",
+ "--skip-update-check",
+ filepath.Join("testdata", path, "test"))
+ results = golden.Get(GinkgoT(), filepath.FromSlash(fmt.Sprintf("%s/result.yml", path)))
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ session.Wait()
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session.Err.Contents()).Should(BeEmpty())
+ Eventually(session.Out.Contents()).Should(MatchYAML(results))
+ Eventually(session).Should(gexec.Exit(0))
+ })
+
+ It("packs successfully given an orb containing local executors and commands in folder", func() {
+ command = exec.Command(pathCLI,
+ "config", "pack",
+ "--skip-update-check",
+ "testdata/myorb/test")
+
+ results = golden.Get(GinkgoT(), filepath.FromSlash("myorb/result.yml"))
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ session.Wait()
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session.Err.Contents()).Should(BeEmpty())
+ Eventually(session.Out.Contents()).Should(MatchYAML(results))
+ Eventually(session).Should(gexec.Exit(0))
+ })
+
+ It("packs as expected given a large nested config including rails orbs", func() {
+ var path string = "test-with-large-nested-rails-orb"
+ command = exec.Command(pathCLI,
+ "config", "pack",
+ "--skip-update-check",
+ filepath.Join("testdata", path, "test"))
+ results = golden.Get(GinkgoT(), filepath.FromSlash(fmt.Sprintf("%s/result.yml", path)))
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ session.Wait()
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session.Err.Contents()).Should(BeEmpty())
+ Eventually(session.Out.Contents()).Should(MatchYAML(results))
+ Eventually(session).Should(gexec.Exit(0))
+ })
+
+ It("prints an error given a config which is a list and not a map", func() {
+ config := clitest.OpenTmpFile(filepath.Join(tempSettings.Home, "myorb"), "config.yaml")
+ command = exec.Command(pathCLI,
+ "config", "pack",
+ "--skip-update-check",
+ config.RootDir,
+ )
+ config.Write([]byte(`[]`))
+
+ expected := fmt.Sprintf("Error: Failed trying to marshal the tree to YAML : expected a map, got a `[]interface {}` which is not supported at this time for \"%s\"\n", config.Path)
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ Expect(err).ShouldNot(HaveOccurred())
+ stderr := session.Wait().Err.Contents()
+ Expect(string(stderr)).To(Equal(expected))
+ Eventually(session).Should(clitest.ShouldFail())
+ config.Close()
+ })
+
Describe("with a large nested config including rails orb", func() {
BeforeEach(func() {
var path string = "test-with-large-nested-rails-orb"
@@ -146,223 +244,5 @@ var _ = Describe("Config", func() {
Eventually(session).Should(clitest.ShouldFail())
})
})
-
- Describe("validating configs", func() {
- config := "version: 2.1"
- var expReq string
-
- BeforeEach(func() {
- command = exec.Command(pathCLI,
- "config", "validate",
- "--skip-update-check",
- "--token", token,
- "--host", tempSettings.TestServer.URL(),
- "-",
- )
-
- stdin, err := command.StdinPipe()
- Expect(err).ToNot(HaveOccurred())
- _, err = io.WriteString(stdin, config)
- Expect(err).ToNot(HaveOccurred())
- stdin.Close()
-
- query := `query ValidateConfig ($config: String!, $pipelineParametersJson: String, $pipelineValues: [StringKeyVal!], $orgSlug: String) {
- buildConfig(configYaml: $config, pipelineValues: $pipelineValues) {
- valid,
- errors { message },
- sourceYaml,
- outputYaml
- }
- }`
-
- r := graphql.NewRequest(query)
- r.Variables["config"] = config
- r.Variables["pipelineValues"] = pipeline.PrepareForGraphQL(pipeline.LocalPipelineValues())
-
- req, err := r.Encode()
- Expect(err).ShouldNot(HaveOccurred())
- expReq = req.String()
- })
-
- It("returns an error when validating a config", func() {
- expResp := `{
- "buildConfig": {
- "errors": [
- {"message": "error1"}
- ]
- }
- }`
-
- tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{
- Status: http.StatusOK,
- Request: expReq,
- Response: expResp,
- })
-
- session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
- Expect(err).ShouldNot(HaveOccurred())
- Eventually(session.Err, time.Second*3).Should(gbytes.Say("Error: error1"))
- Eventually(session).Should(clitest.ShouldFail())
- })
-
- It("returns successfully when validating a config", func() {
- expResp := `{
- "buildConfig": {}
- }`
-
- tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{
- Status: http.StatusOK,
- Request: expReq,
- Response: expResp,
- })
-
- session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
- Expect(err).ShouldNot(HaveOccurred())
- Eventually(session.Out, time.Second*3).Should(gbytes.Say("Config input is valid."))
- Eventually(session).Should(gexec.Exit(0))
- })
- })
-
- Describe("validating configs with pipeline parameters", func() {
- config := "version: 2.1"
- var expReq string
-
- BeforeEach(func() {
- command = exec.Command(pathCLI,
- "config", "process",
- "--skip-update-check",
- "--token", token,
- "--host", tempSettings.TestServer.URL(),
- "--pipeline-parameters", `{"foo": "test", "bar": true, "baz": 10}`,
- "-",
- )
-
- stdin, err := command.StdinPipe()
- Expect(err).ToNot(HaveOccurred())
- _, err = io.WriteString(stdin, config)
- Expect(err).ToNot(HaveOccurred())
- stdin.Close()
-
- query := `query ValidateConfig ($config: String!, $pipelineParametersJson: String, $pipelineValues: [StringKeyVal!], $orgSlug: String) {
- buildConfig(configYaml: $config, pipelineValues: $pipelineValues, pipelineParametersJson: $pipelineParametersJson) {
- valid,
- errors { message },
- sourceYaml,
- outputYaml
- }
- }`
-
- r := graphql.NewRequest(query)
- r.Variables["config"] = config
- r.Variables["pipelineValues"] = pipeline.PrepareForGraphQL(pipeline.LocalPipelineValues())
-
- pipelineParams, err := json.Marshal(pipeline.Parameters{
- "foo": "test",
- "bar": true,
- "baz": 10,
- })
- Expect(err).ToNot(HaveOccurred())
- r.Variables["pipelineParametersJson"] = string(pipelineParams)
-
- req, err := r.Encode()
- Expect(err).ShouldNot(HaveOccurred())
- expReq = req.String()
- })
-
- It("returns successfully when validating a config", func() {
- expResp := `{
- "buildConfig": {}
- }`
-
- tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{
- Status: http.StatusOK,
- Request: expReq,
- Response: expResp,
- })
-
- session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
- Expect(err).ShouldNot(HaveOccurred())
- Eventually(session).Should(gexec.Exit(0))
- })
- })
-
- Describe("validating configs with private orbs", func() {
- config := "version: 2.1"
- orgSlug := "circleci"
- var expReq string
-
- BeforeEach(func() {
- command = exec.Command(pathCLI,
- "config", "validate",
- "--skip-update-check",
- "--token", token,
- "--host", tempSettings.TestServer.URL(),
- "--org-slug", orgSlug,
- "-",
- )
-
- stdin, err := command.StdinPipe()
- Expect(err).ToNot(HaveOccurred())
- _, err = io.WriteString(stdin, config)
- Expect(err).ToNot(HaveOccurred())
- stdin.Close()
-
- query := `query ValidateConfig ($config: String!, $pipelineParametersJson: String, $pipelineValues: [StringKeyVal!], $orgSlug: String) {
- buildConfig(configYaml: $config, pipelineValues: $pipelineValues, orgSlug: $orgSlug) {
- valid,
- errors { message },
- sourceYaml,
- outputYaml
- }
- }`
-
- r := graphql.NewRequest(query)
- r.Variables["config"] = config
- r.Variables["orgSlug"] = orgSlug
- r.Variables["pipelineValues"] = pipeline.PrepareForGraphQL(pipeline.LocalPipelineValues())
-
- req, err := r.Encode()
- Expect(err).ShouldNot(HaveOccurred())
- expReq = req.String()
- })
-
- It("returns an error when validating a config with a private orb", func() {
- expResp := `{
- "buildConfig": {
- "errors": [
- {"message": "permission denied"}
- ]
- }
- }`
-
- tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{
- Status: http.StatusOK,
- Request: expReq,
- Response: expResp,
- })
-
- session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
- Expect(err).ShouldNot(HaveOccurred())
- Eventually(session.Err, time.Second*3).Should(gbytes.Say("Error: permission denied"))
- Eventually(session).Should(clitest.ShouldFail())
- })
-
- It("returns successfully when validating a config with private orbs", func() {
- expResp := `{
- "buildConfig": {}
- }`
-
- tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{
- Status: http.StatusOK,
- Request: expReq,
- Response: expResp,
- })
-
- session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
- Expect(err).ShouldNot(HaveOccurred())
- Eventually(session.Out, time.Second*3).Should(gbytes.Say("Config input is valid."))
- Eventually(session).Should(gexec.Exit(0))
- })
- })
})
})
diff --git a/cmd/context.go b/cmd/context.go
index 1e0efb014..bc9ae2d8f 100644
--- a/cmd/context.go
+++ b/cmd/context.go
@@ -3,12 +3,13 @@ package cmd
import (
"bufio"
"fmt"
- "io/ioutil"
+ "io"
"os"
"strings"
"time"
"github.com/CircleCI-Public/circleci-cli/api"
+ "github.com/google/uuid"
"github.com/olekukonko/tablewriter"
"github.com/pkg/errors"
@@ -16,6 +17,11 @@ import (
"github.com/spf13/cobra"
)
+var (
+ orgID *string
+ integrationTesting bool
+)
+
func newContextCommand(config *settings.Config) *cobra.Command {
var contextClient api.ContextInterface
@@ -25,6 +31,11 @@ func newContextCommand(config *settings.Config) *cobra.Command {
return e
}
+ // Ensure does not fallback to graph for testing.
+ if integrationTesting {
+ return validateToken(config)
+ }
+
// If we're on cloud, we're good.
if config.Host == defaultHost || contextClient.(*api.ContextRestClient).EnsureExists() == nil {
return validateToken(config)
@@ -36,9 +47,12 @@ func newContextCommand(config *settings.Config) *cobra.Command {
}
command := &cobra.Command{
- Use: "context",
- Short: "Contexts provide a mechanism for securing and sharing environment variables across projects. The environment variables are defined as name/value pairs and are injected at runtime.",
- }
+ Use: "context",
+ Long: `
+Contexts provide a mechanism for securing and sharing environment variables across
+projects. The environment variables are defined as name/value pairs and
+are injected at runtime.`,
+ Short: "For securing and sharing environment variables across projects"}
listCommand := &cobra.Command{
Short: "List all contexts",
@@ -82,13 +96,18 @@ func newContextCommand(config *settings.Config) *cobra.Command {
createContextCommand := &cobra.Command{
Short: "Create a new context",
- Use: "create ",
+ Use: "create [] [] ",
PreRunE: initClient,
RunE: func(cmd *cobra.Command, args []string) error {
- return createContext(contextClient, args[0], args[1], args[2])
+ return createContext(cmd, contextClient, args)
},
- Args: cobra.ExactArgs(3),
+ Args: cobra.RangeArgs(1, 3),
+ Annotations: make(map[string]string),
+ Example: ` circleci context create github OrgName contextName
+circleci context create contextName --org-id "your-org-id-here"`,
}
+ createContextCommand.Annotations["[]"] = `Your VCS provider, can be either "github" or "bitbucket". Optional when passing org-id flag.`
+ createContextCommand.Annotations["[]"] = `The name used for your organization. Optional when passing org-id flag.`
force := false
deleteContextCommand := &cobra.Command{
@@ -103,6 +122,12 @@ func newContextCommand(config *settings.Config) *cobra.Command {
deleteContextCommand.Flags().BoolVarP(&force, "force", "f", false, "Delete the context without asking for confirmation.")
+ orgID = createContextCommand.Flags().String("org-id", "", "The id of your organization.")
+ createContextCommand.Flags().BoolVar(&integrationTesting, "integration-testing", false, "Enable test mode to setup rest API")
+ if err := createContextCommand.Flags().MarkHidden("integration-testing"); err != nil {
+ panic(err)
+ }
+
command.AddCommand(listCommand)
command.AddCommand(showContextCommand)
command.AddCommand(storeCommand)
@@ -164,7 +189,7 @@ func showContext(client api.ContextInterface, vcsType, orgName, contextName stri
func readSecretValue() (string, error) {
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) == 0 {
- bytes, err := ioutil.ReadAll(os.Stdin)
+ bytes, err := io.ReadAll(os.Stdin)
return string(bytes), err
} else {
fmt.Print("Enter secret value and press enter: ")
@@ -174,9 +199,22 @@ func readSecretValue() (string, error) {
}
}
-func createContext(client api.ContextInterface, vcsType, orgName, contextName string) error {
- err := client.CreateContext(vcsType, orgName, contextName)
- return err
+// createContext determines if the context is being created via orgid or vcs and org name
+// and navigates to corresponding function accordingly
+func createContext(cmd *cobra.Command, client api.ContextInterface, args []string) error {
+ //skip if no orgid provided
+ if orgID != nil && strings.TrimSpace(*orgID) != "" && len(args) == 1 {
+ _, err := uuid.Parse(*orgID)
+
+ if err == nil {
+ return client.CreateContextWithOrgID(orgID, args[0])
+ }
+
+ //skip if no vcs type and org name provided
+ } else if len(args) == 3 {
+ return client.CreateContext(args[0], args[1], args[2])
+ }
+ return cmd.Help()
}
func removeEnvVar(client api.ContextInterface, vcsType, orgName, contextName, varName string) error {
diff --git a/cmd/context_test.go b/cmd/context_test.go
index 934b8b42c..619ed2089 100644
--- a/cmd/context_test.go
+++ b/cmd/context_test.go
@@ -1,6 +1,8 @@
package cmd_test
import (
+ "fmt"
+ "net/http"
"os/exec"
"github.com/CircleCI-Public/circleci-cli/clitest"
@@ -11,32 +13,124 @@ import (
)
var _ = Describe("Context integration tests", func() {
- Describe("when listing contexts without a token", func() {
- var (
- command *exec.Cmd
- tempSettings *clitest.TempSettings
- )
-
- BeforeEach(func() {
- tempSettings = clitest.WithTempSettings()
- command = commandWithHome(pathCLI, tempSettings.Home,
- "context", "list", "github", "foo",
- "--skip-update-check",
- "--token", "",
- )
- })
+ var (
+ tempSettings *clitest.TempSettings
+ token string = "testtoken"
+ command *exec.Cmd
+ contextName string = "foo-context"
+ orgID string = "bb604b45-b6b0-4b81-ad80-796f15eddf87"
+ vcsType string = "BITBUCKET"
+ orgName string = "test-org"
+ )
+
+ BeforeEach(func() {
+ tempSettings = clitest.WithTempSettings()
+ })
+
+ AfterEach(func() {
+ tempSettings.Close()
+ })
- It("instructs the user to run 'circleci setup' and create a new token", func() {
- By("running the command")
- session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ Context("create, with interactive prompts", func() {
- Expect(err).ShouldNot(HaveOccurred())
- Eventually(session.Err).Should(gbytes.Say(`Error: please set a token with 'circleci setup'
+ Describe("when listing contexts without a token", func() {
+ BeforeEach(func() {
+ command = commandWithHome(pathCLI, tempSettings.Home,
+ "context", "list", "github", "foo",
+ "--skip-update-check",
+ "--token", "",
+ )
+ })
+
+ It("instructs the user to run 'circleci setup' and create a new token", func() {
+ By("running the command")
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session.Err).Should(gbytes.Say(`Error: please set a token with 'circleci setup'
You can create a new personal API token here:
https://circleci.com/account/api`))
- Eventually(session).Should(clitest.ShouldFail())
+ Eventually(session).Should(clitest.ShouldFail())
+ })
})
})
- // TODO: add integration tests for happy path cases
+ Context("create, with interactive prompts", func() {
+ //tests context creation via orgid
+ Describe("using an org id to create a context", func() {
+
+ BeforeEach(func() {
+ command = commandWithHome(pathCLI, tempSettings.Home,
+ "context", "create",
+ "--skip-update-check",
+ "--token", token,
+ "--host", tempSettings.TestServer.URL(),
+ "--integration-testing",
+ contextName,
+ "--org-id", fmt.Sprintf(`"%s"`, orgID),
+ )
+ })
+
+ It("should create new context using an org id", func() {
+ By("setting up a mock server")
+ tempSettings.AppendRESTPostHandler(clitest.MockRequestResponse{
+ Status: http.StatusOK,
+ Request: fmt.Sprintf(`{"name": "%s","owner":{"id":"\"%s\""}}`, contextName, orgID),
+ Response: fmt.Sprintf(`{"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "%s", "created_at": "2015-09-21T17:29:21.042Z" }`, contextName),
+ })
+
+ By("running the command")
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session).Should(gexec.Exit(0))
+ })
+ })
+ //tests context creation via orgname and vcs type
+ Describe("using an vcs and org name to create a context", func() {
+ BeforeEach(func() {
+ command = exec.Command(pathCLI,
+ "context", "create",
+ "--skip-update-check",
+ "--token", token,
+ "--host", tempSettings.TestServer.URL(),
+ "--integration-testing",
+ vcsType,
+ orgName,
+ contextName,
+ )
+ })
+
+ It("user creating new context", func() {
+ By("setting up a mock server")
+
+ tempSettings.AppendRESTPostHandler(clitest.MockRequestResponse{
+ Status: http.StatusOK,
+ Request: fmt.Sprintf(`{"name": "%s","owner":{"slug":"%s"}}`, contextName, vcsType+"/"+orgName),
+ Response: fmt.Sprintf(`{"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "%s", "created_at": "2015-09-21T17:29:21.042Z" }`, contextName),
+ })
+
+ By("running the command")
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session).Should(gexec.Exit(0))
+ })
+
+ It("prints all in-band errors returned by the API", func() {
+ By("setting up a mock server")
+ tempSettings.AppendRESTPostHandler(clitest.MockRequestResponse{
+ Status: http.StatusInternalServerError,
+ Request: fmt.Sprintf(`{"name": "%s","owner":{"slug":"%s"}}`, contextName, vcsType+"/"+orgName),
+ Response: fmt.Sprintf(`{"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "%s", "created_at": "2015-09-21T17:29:21.042Z" }`, contextName),
+ ErrorResponse: `{ "message": "ignored error" }`,
+ })
+ By("running the command")
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session.Err).Should(gbytes.Say(`Error: ignored error`))
+ Eventually(session).ShouldNot(gexec.Exit(0))
+ })
+ })
+ })
})
diff --git a/cmd/env.go b/cmd/env.go
new file mode 100644
index 000000000..9d9ed5fe0
--- /dev/null
+++ b/cmd/env.go
@@ -0,0 +1,47 @@
+package cmd
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/a8m/envsubst"
+ "github.com/spf13/cobra"
+)
+
+func newEnvCmd() *cobra.Command {
+ var envCmd = &cobra.Command{
+ Use: "env",
+ Short: "Manage environment variables",
+ }
+ var substCmd = &cobra.Command{
+ Use: "subst",
+ Short: "Substitute environment variables in a string",
+ RunE: substRunE,
+ }
+ envCmd.AddCommand(substCmd)
+ return envCmd
+}
+
+// Accepts a string as an argument, or reads from stdin if no argument is provided.
+func substRunE(cmd *cobra.Command, args []string) error {
+ var input string
+ if len(args) > 0 {
+ input = args[0]
+ } else {
+ // Read from stdin
+ b, err := io.ReadAll(cmd.InOrStdin())
+ if err != nil {
+ return err
+ }
+ input = string(b)
+ }
+ if input == "" {
+ return nil
+ }
+ output, err := envsubst.String(input)
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprint(cmd.OutOrStdout(), output)
+ return err
+}
diff --git a/cmd/env_test.go b/cmd/env_test.go
new file mode 100644
index 000000000..a8ea0e27e
--- /dev/null
+++ b/cmd/env_test.go
@@ -0,0 +1,97 @@
+package cmd
+
+import (
+ "bytes"
+ "os"
+ "testing"
+
+ "gotest.tools/v3/assert"
+)
+
+func TestSubstRunE(t *testing.T) {
+ // Set environment variables for testing
+ err := os.Setenv("ENV_NAME", "world")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ testCases := []struct {
+ name string
+ input string
+ output string
+ }{
+ {
+ name: "substitute variables",
+ input: "Hello $ENV_NAME!",
+ output: "Hello world!",
+ },
+ {
+ name: "no variables to substitute",
+ input: "Hello, world!",
+ output: "Hello, world!",
+ },
+ {
+ name: "empty input",
+ input: "",
+ output: "",
+ },
+ {
+ name: "no variables JSON",
+ input: `{"foo": "bar"}`,
+ output: `{"foo": "bar"}`,
+ },
+ {
+ name: "substitute variables JSON",
+ input: `{"foo": "$ENV_NAME"}`,
+ output: `{"foo": "world"}`,
+ },
+ {
+ name: "no variables key=value",
+ input: `foo=bar`,
+ output: `foo=bar`,
+ },
+ }
+
+ // Run tests for each test case as argument
+ for _, tc := range testCases {
+ t.Run("arg: "+tc.name, func(t *testing.T) {
+ // Set up test command
+ cmd := newEnvCmd()
+
+ // Capture output
+ outputBuf := bytes.Buffer{}
+ cmd.SetOut(&outputBuf)
+
+ // Run command
+ cmd.SetArgs([]string{"subst", tc.input})
+ err := cmd.Execute()
+
+ // Check output and error
+ assert.NilError(t, err)
+ assert.Equal(t, tc.output, outputBuf.String())
+ })
+ }
+ // Run tests for each test case as stdin
+ for _, tc := range testCases {
+ t.Run("stdin: "+tc.name, func(t *testing.T) {
+ // Set up test command
+ cmd := newEnvCmd()
+
+ // Set up input
+ inputBuf := bytes.NewBufferString(tc.input)
+ cmd.SetIn(inputBuf)
+
+ // Capture output
+ outputBuf := bytes.Buffer{}
+ cmd.SetOut(&outputBuf)
+
+ // Run command
+ cmd.SetArgs([]string{"subst"})
+ err = cmd.Execute()
+
+ // Check output and error
+ assert.NilError(t, err)
+ assert.Equal(t, tc.output, outputBuf.String())
+ })
+ }
+}
diff --git a/cmd/follow.go b/cmd/follow.go
index 609376f89..31eb3bbae 100644
--- a/cmd/follow.go
+++ b/cmd/follow.go
@@ -14,31 +14,38 @@ type options struct {
cfg *settings.Config
}
+// followProject gets the remote data and attempts to follow its git project
func followProject(opts options) error {
remote, err := git.InferProjectFromGitRemotes()
-
if err != nil {
return errors.Wrap(err, errorMessage)
}
- vcsShort := "gh"
- if remote.VcsType == "BITBUCKET" {
- vcsShort = "bb"
- }
- res, err := api.FollowProject(*opts.cfg, vcsShort, remote.Organization, remote.Project)
- if err != nil {
- return err
+ //check that project url contains github or bitbucket; our legacy vcs
+ if remote.VcsType == git.GitHub || remote.VcsType == git.Bitbucket {
+ vcsShort := "gh"
+ if remote.VcsType == git.Bitbucket {
+ vcsShort = "bb"
+ }
+ res, err := api.FollowProject(*opts.cfg, vcsShort, remote.Organization, remote.Project)
+ if err != nil {
+ return err
+ }
+ if res.Followed {
+ fmt.Println("Project successfully followed!")
+ } else if res.Message == "Project not found" {
+ fmt.Println("Unable to determine project slug for CircleCI (slug is case sensitive).")
+ }
+
+ } else {
+ //if not warn user their vcs is not supported
+ return errors.New(errorMessage)
}
- if res.Followed {
- fmt.Println("Project successfully followed!")
- } else if res.Message == "Project not found" {
- fmt.Println("Unable to determine project slug for CircleCI (slug is case sensitive).")
- }
-
return nil
}
+// followProjectCommand follow cobra command creation
func followProjectCommand(config *settings.Config) *cobra.Command {
opts := options{
cfg: config,
diff --git a/cmd/info/info.go b/cmd/info/info.go
new file mode 100644
index 000000000..1ed3028fe
--- /dev/null
+++ b/cmd/info/info.go
@@ -0,0 +1,69 @@
+package info
+
+import (
+ "github.com/CircleCI-Public/circleci-cli/api/info"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/olekukonko/tablewriter"
+
+ "github.com/spf13/cobra"
+)
+
+// infoOptions info command options
+type infoOptions struct {
+ cfg *settings.Config
+ validator validator.Validator
+}
+
+// NewInfoCommand information cobra command creation
+func NewInfoCommand(config *settings.Config, preRunE validator.Validator) *cobra.Command {
+ client, _ := info.NewInfoClient(*config)
+
+ opts := infoOptions{
+ cfg: config,
+ validator: preRunE,
+ }
+ infoCommand := &cobra.Command{
+ Use: "info",
+ Short: "Check information associated to your user account.",
+ }
+ orgInfoCmd := orgInfoCommand(client, opts)
+ infoCommand.AddCommand(orgInfoCmd)
+
+ return infoCommand
+}
+
+// orgInfoCommand organization information subcommand cobra command creation
+func orgInfoCommand(client info.InfoClient, opts infoOptions) *cobra.Command {
+ return &cobra.Command{
+ Use: "org",
+ Short: "View your Organizations' information",
+ Long: `View your Organizations' names and ids.`,
+ PreRunE: opts.validator,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return getOrgInformation(cmd, client)
+ },
+ Annotations: make(map[string]string),
+ Example: `circleci info org`,
+ }
+}
+
+// getOrgInformation gets all of the users organizations' information
+func getOrgInformation(cmd *cobra.Command, client info.InfoClient) error {
+ resp, err := client.GetInfo()
+ if err != nil {
+ return err
+ }
+
+ table := tablewriter.NewWriter(cmd.OutOrStdout())
+
+ table.SetHeader([]string{"ID", "Name"})
+
+ for _, info := range *resp {
+ table.Append([]string{
+ info.ID, info.Name,
+ })
+ }
+ table.Render()
+ return nil
+}
diff --git a/cmd/info/info_test.go b/cmd/info/info_test.go
new file mode 100644
index 000000000..19bda1417
--- /dev/null
+++ b/cmd/info/info_test.go
@@ -0,0 +1,130 @@
+package info
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/spf13/cobra"
+ "gotest.tools/v3/assert"
+)
+
+func TestGetOrgSuccess(t *testing.T) {
+ id := "id"
+ name := "name"
+
+ // Test server
+ var serverHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/me/collaborations")
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(fmt.Sprintf(`[{"id": "%s", "name": "%s"}]`, id, name)))
+ assert.NilError(t, err)
+ }
+ server := httptest.NewServer(serverHandler)
+ defer server.Close()
+
+ // Test command
+ cmd, stdout, _ := scaffoldCMD(server.URL, defaultValidator)
+ args := []string{
+ "org",
+ }
+ cmd.SetArgs(args)
+
+ // Execute
+ err := cmd.Execute()
+
+ // Asserts
+ assert.NilError(t, err)
+ assert.Equal(t, stdout.String(), `+----+------+
+| ID | NAME |
++----+------+
+| id | name |
++----+------+
+`)
+}
+
+func TestGetOrgError(t *testing.T) {
+ errorMessage := "server error message"
+
+ // Test server
+ var serverHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/me/collaborations")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, err := w.Write([]byte(fmt.Sprintf(`{"message": "%s"}`, errorMessage)))
+ assert.NilError(t, err)
+ }
+ server := httptest.NewServer(serverHandler)
+ defer server.Close()
+
+ // Test command
+ cmd, _, _ := scaffoldCMD(server.URL, defaultValidator)
+ args := []string{
+ "org",
+ }
+ cmd.SetArgs(args)
+
+ // Execute
+ err := cmd.Execute()
+
+ // Asserts
+ assert.Error(t, err, errorMessage)
+}
+
+func TestFailedValidator(t *testing.T) {
+ errorMessage := "validator error"
+
+ // Test server
+ var serverHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/me/collaborations")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, err := w.Write([]byte(fmt.Sprintf(`{"message": "%s"}`, errorMessage)))
+ assert.NilError(t, err)
+ }
+ server := httptest.NewServer(serverHandler)
+ defer server.Close()
+
+ // Test command
+ cmd, _, _ := scaffoldCMD(server.URL, func(_ *cobra.Command, _ []string) error {
+ return fmt.Errorf(errorMessage)
+ })
+ args := []string{
+ "org",
+ }
+ cmd.SetArgs(args)
+
+ // Execute
+ err := cmd.Execute()
+
+ // Asserts
+ assert.Error(t, err, errorMessage)
+}
+
+func defaultValidator(cmd *cobra.Command, args []string) error {
+ return nil
+}
+
+func scaffoldCMD(
+ baseURL string,
+ validator validator.Validator,
+) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
+ config := &settings.Config{
+ Token: "testtoken",
+ HTTPClient: http.DefaultClient,
+ Host: baseURL,
+ }
+ cmd := NewInfoCommand(config, validator)
+
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ cmd.SetOut(stdout)
+ cmd.SetErr(stderr)
+
+ return cmd, stdout, stderr
+}
diff --git a/cmd/namespace.go b/cmd/namespace.go
index 69b9728f3..0e5fb8cc5 100644
--- a/cmd/namespace.go
+++ b/cmd/namespace.go
@@ -8,6 +8,7 @@ import (
"github.com/CircleCI-Public/circleci-cli/api/graphql"
"github.com/CircleCI-Public/circleci-cli/prompt"
"github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/google/uuid"
"github.com/spf13/cobra"
)
@@ -18,6 +19,7 @@ type namespaceOptions struct {
// Allows user to skip y/n confirm when creating a namespace
noPrompt bool
+ orgID *string
// This lets us pass in our own interface for testing
tty createNamespaceUserInterface
// Linked with --integration-testing flag for stubbing UI in gexec tests
@@ -55,7 +57,7 @@ func newNamespaceCommand(config *settings.Config) *cobra.Command {
}
createCmd := &cobra.Command{
- Use: "create ",
+ Use: "create [] []",
Short: "Create a namespace",
Long: `Create a namespace.
Please note that at this time all namespaces created in the registry are world-readable.`,
@@ -65,28 +67,31 @@ Please note that at this time all namespaces created in the registry are world-r
return validateToken(opts.cfg)
},
- RunE: func(_ *cobra.Command, _ []string) error {
+ RunE: func(cmd *cobra.Command, _ []string) error {
if opts.integrationTesting {
opts.tty = createNamespaceTestUI{
confirm: true,
}
}
- return createNamespace(opts)
+ return createNamespace(cmd, opts)
},
- Args: cobra.ExactArgs(3),
+ Args: cobra.RangeArgs(1, 3),
Annotations: make(map[string]string),
+ Example: ` circleci namespace create NamespaceName github OrgName
+ circleci namespace create NamespaceName --org-id "your-org-id-here"`,
}
createCmd.Annotations[""] = "The name to give your new namespace"
- createCmd.Annotations[""] = `Your VCS provider, can be either "github" or "bitbucket"`
- createCmd.Annotations[""] = `The name used for your organization`
+ createCmd.Annotations["[]"] = `Your VCS provider, can be either "github" or "bitbucket". Optional when passing org-id flag.`
+ createCmd.Annotations["[]"] = `The name used for your organization. Optional when passing org-id flag.`
createCmd.Flags().BoolVar(&opts.integrationTesting, "integration-testing", false, "Enable test mode to bypass interactive UI.")
if err := createCmd.Flags().MarkHidden("integration-testing"); err != nil {
panic(err)
}
createCmd.Flags().BoolVar(&opts.noPrompt, "no-prompt", false, "Disable prompt to bypass interactive UI.")
+ opts.orgID = createCmd.Flags().String("org-id", "", "The id of your organization.")
namespaceCmd.AddCommand(createCmd)
@@ -103,9 +108,32 @@ func deleteNamespaceAlias(opts namespaceOptions) error {
return nil
}
-func createNamespace(opts namespaceOptions) error {
- namespaceName := opts.args[0]
+func createNamespaceWithOrgId(opts namespaceOptions, namespaceName, orgId string) error {
+ if !opts.noPrompt {
+ fmt.Printf(`You are creating a namespace called "%s".
+
+This is the only namespace permitted for your organization with id %s.
+
+To change the namespace, you will have to contact CircleCI customer support.
+
+`, namespaceName, orgId)
+ }
+
+ confirm := fmt.Sprintf("Are you sure you wish to create the namespace: `%s`", namespaceName)
+ if opts.noPrompt || opts.tty.askUserToConfirm(confirm) {
+ _, err := api.CreateNamespaceWithOwnerID(opts.cl, namespaceName, orgId)
+
+ if err != nil {
+ return err
+ }
+ fmt.Printf("Namespace `%s` created.\n", namespaceName)
+ fmt.Println("Please note that any orbs you publish in this namespace are open orbs and are world-readable.")
+ }
+ return nil
+}
+
+func createNamespaceWithVcsTypeAndOrgName(opts namespaceOptions, namespaceName, vcsType, orgName string) error {
if !opts.noPrompt {
fmt.Printf(`You are creating a namespace called "%s".
@@ -119,7 +147,6 @@ To change the namespace, you will have to contact CircleCI customer support.
confirm := fmt.Sprintf("Are you sure you wish to create the namespace: `%s`", namespaceName)
if opts.noPrompt || opts.tty.askUserToConfirm(confirm) {
_, err := api.CreateNamespace(opts.cl, namespaceName, opts.args[2], strings.ToUpper(opts.args[1]))
-
if err != nil {
return err
}
@@ -127,10 +154,25 @@ To change the namespace, you will have to contact CircleCI customer support.
fmt.Printf("Namespace `%s` created.\n", namespaceName)
fmt.Println("Please note that any orbs you publish in this namespace are open orbs and are world-readable.")
}
-
return nil
}
+func createNamespace(cmd *cobra.Command, opts namespaceOptions) error {
+ namespaceName := opts.args[0]
+ //skip if no orgid provided
+ if opts.orgID != nil && strings.TrimSpace(*opts.orgID) != "" {
+ _, err := uuid.Parse(*opts.orgID)
+ if err == nil {
+ return createNamespaceWithOrgId(opts, namespaceName, *opts.orgID)
+ }
+
+ //skip if no vcs type and org name provided
+ } else if len(opts.args) == 3 {
+ return createNamespaceWithVcsTypeAndOrgName(opts, namespaceName, opts.args[1], opts.args[2])
+ }
+ return cmd.Help()
+}
+
func renameNamespace(opts namespaceOptions) error {
oldName := opts.args[0]
newName := opts.args[1]
diff --git a/cmd/namespace_test.go b/cmd/namespace_test.go
index 0cb16564f..189e4901e 100644
--- a/cmd/namespace_test.go
+++ b/cmd/namespace_test.go
@@ -28,7 +28,62 @@ var _ = Describe("Namespace integration tests", func() {
})
Context("create, with interactive prompts", func() {
- Describe("registering a namespace", func() {
+ Describe("registering a namespace with orgID", func() {
+ BeforeEach(func() {
+ command = exec.Command(pathCLI,
+ "namespace", "create",
+ "--skip-update-check",
+ "--token", token,
+ "--host", tempSettings.TestServer.URL(),
+ "--integration-testing",
+ "foo-ns",
+ "--org-id", `"bb604b45-b6b0-4b81-ad80-796f15eddf87"`,
+ )
+ })
+
+ It("works with organizationID", func() {
+ By("setting up a mock server")
+
+ gqlOrganizationResponse := `{
+ "organization": {
+ "name": "test-org",
+ "id": "bb604b45-b6b0-4b81-ad80-796f15eddf87"
+ }
+ }`
+
+ expectedOrganizationRequest := `{
+ "query": "\n\t\t\tmutation($name: String!, $organizationId: UUID!) {\n\t\t\t\tcreateNamespace(\n\t\t\t\t\tname: $name,\n\t\t\t\t\torganizationId: $organizationId\n\t\t\t\t) {\n\t\t\t\t\tnamespace {\n\t\t\t\t\t\tid\n\t\t\t\t\t}\n\t\t\t\t\terrors {\n\t\t\t\t\t\tmessage\n\t\t\t\t\t\ttype\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}",
+ "variables": {
+ "name": "foo-ns",
+ "organizationId": "\"bb604b45-b6b0-4b81-ad80-796f15eddf87\""
+ }
+ }`
+
+ tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{
+ Status: http.StatusOK,
+ Request: expectedOrganizationRequest,
+ Response: gqlOrganizationResponse})
+
+ By("running the command")
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ Expect(err).ShouldNot(HaveOccurred())
+ Eventually(session).Should(gexec.Exit(0))
+
+ stdout := session.Wait().Out.Contents()
+
+ Expect(string(stdout)).To(ContainSubstring(fmt.Sprintf(`You are creating a namespace called "%s".
+
+This is the only namespace permitted for your organization with id "%s".
+
+To change the namespace, you will have to contact CircleCI customer support.
+
+Are you sure you wish to create the namespace: %s
+Namespace %s created.
+Please note that any orbs you publish in this namespace are open orbs and are world-readable.`, "foo-ns", "bb604b45-b6b0-4b81-ad80-796f15eddf87", "`foo-ns`", "`foo-ns`")))
+ })
+ })
+
+ Describe("registering a namespace with OrgName and OrgVcs", func() {
BeforeEach(func() {
command = exec.Command(pathCLI,
"namespace", "create",
@@ -230,7 +285,8 @@ Please note that any orbs you publish in this namespace are open orbs and are wo
session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
Expect(err).ShouldNot(HaveOccurred())
- Eventually(session.Err).Should(gbytes.Say("Error: error1\nerror2"))
+ Eventually(session.Err).Should(gbytes.Say(`Error: error1
+error2`))
Eventually(session).ShouldNot(gexec.Exit(0))
})
})
diff --git a/cmd/open.go b/cmd/open.go
index 52d134b72..6f1772b1d 100644
--- a/cmd/open.go
+++ b/cmd/open.go
@@ -11,6 +11,12 @@ import (
"github.com/spf13/cobra"
)
+// errorMessage string containing the error message displayed in both the open command and the follow command
+var errorMessage = `
+This command is intended to be run from a git repository with a remote named 'origin' that is hosted on Github or Bitbucket only.
+We are not currently supporting any other hosts.`
+
+// projectUrl uses the provided values to create the url to open
func projectUrl(remote *git.Remote) string {
return fmt.Sprintf("https://app.circleci.com/pipelines/%s/%s/%s",
url.PathEscape(strings.ToLower(string(remote.VcsType))),
@@ -18,24 +24,22 @@ func projectUrl(remote *git.Remote) string {
url.PathEscape(remote.Project))
}
-var errorMessage = `
-Unable detect which URL should be opened. This command is intended to be run from
-a git repository with a remote named 'origin' that is hosted on Github or Bitbucket
-Error`
-
+// openProjectInBrowser takes the created url and opens a browser to it
func openProjectInBrowser() error {
-
remote, err := git.InferProjectFromGitRemotes()
-
if err != nil {
return errors.Wrap(err, errorMessage)
}
-
- return browser.OpenURL(projectUrl(remote))
+ //check that project url contains github or bitbucket; our legacy vcs
+ if remote.VcsType == git.GitHub || remote.VcsType == git.Bitbucket {
+ return browser.OpenURL(projectUrl(remote))
+ }
+ //if not warn user their vcs is not supported
+ return errors.New(errorMessage)
}
+// newOpenCommand creates the cli command open
func newOpenCommand() *cobra.Command {
-
openCommand := &cobra.Command{
Use: "open",
Short: "Open the current project in the browser.",
diff --git a/cmd/orb.go b/cmd/orb.go
index 11cfef643..33a06c855 100644
--- a/cmd/orb.go
+++ b/cmd/orb.go
@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
- "io/ioutil"
+ "log"
"net/http"
"os"
"path"
@@ -25,8 +25,10 @@ import (
"github.com/CircleCI-Public/circleci-cli/references"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/CircleCI-Public/circleci-cli/version"
+ "github.com/fatih/color"
"github.com/pkg/errors"
"github.com/spf13/cobra"
+ "golang.org/x/exp/slices"
"gopkg.in/yaml.v3"
"github.com/AlecAivazis/survey/v2"
@@ -34,6 +36,10 @@ import (
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
+
+ "github.com/hexops/gotextdiff"
+ "github.com/hexops/gotextdiff/myers"
+ "github.com/hexops/gotextdiff/span"
)
type orbOptions struct {
@@ -41,6 +47,8 @@ type orbOptions struct {
cl *graphql.Client
args []string
+ color string
+
listUncertified bool
listJSON bool
listDetails bool
@@ -76,6 +84,7 @@ type createOrbTestUI struct {
type orbProtectTemplateRelease struct {
ZipUrl string `json:"zipball_url"`
+ Name string `json:"name"`
}
func (ui createOrbTestUI) askUserToConfirm(message string) bool {
@@ -124,7 +133,7 @@ func newOrbCommand(config *settings.Config) *cobra.Command {
Use: "process ",
Short: "Validate an orb and print its form after all pre-registration processing",
Long: strings.Join([]string{
- "Use `$ circleci orb process` to resolve an orb, and it's dependencies to see how it would be expanded when you publish it to the registry.",
+ "Use `$ circleci orb process` to resolve an orb and its dependencies, to see how it would be expanded when you publish it to the registry.",
"", // purposeful new-line
"This can be helpful for validating an orb and debugging the processed form before publishing.",
}, "\n"),
@@ -310,7 +319,7 @@ Please note that at this time all orbs created in the registry are world-readabl
orbInit := &cobra.Command{
Use: "init ",
- Short: "Initialize a new orb.",
+ Short: "Initialize a new orb project.",
Long: ``,
RunE: func(_ *cobra.Command, _ []string) error {
return initOrb(opts)
@@ -319,6 +328,18 @@ Please note that at this time all orbs created in the registry are world-readabl
}
orbInit.PersistentFlags().BoolVarP(&opts.private, "private", "", false, "initialize a private orb")
+ orbDiff := &cobra.Command{
+ Use: "diff ",
+ Short: "Shows the difference between two versions of the same orb",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return orbDiff(opts)
+ },
+ Args: cobra.ExactArgs(3),
+ Annotations: make(map[string]string),
+ }
+ orbDiff.Annotations[""] = "An orb with only a namespace and a name. This takes this form namespace/orb"
+ orbDiff.PersistentFlags().StringVar(&opts.color, "color", "auto", "Show colored diff. Can be one of \"always\", \"never\", or \"auto\"")
+
orbCreate.Flags().BoolVar(&opts.integrationTesting, "integration-testing", false, "Enable test mode to bypass interactive UI.")
if err := orbCreate.Flags().MarkHidden("integration-testing"); err != nil {
panic(err)
@@ -353,6 +374,7 @@ Please note that at this time all orbs created in the registry are world-readabl
orbCommand.AddCommand(removeCategorizationFromOrbCommand)
orbCommand.AddCommand(listCategoriesCommand)
orbCommand.AddCommand(orbInit)
+ orbCommand.AddCommand(orbDiff)
return orbCommand
}
@@ -368,6 +390,22 @@ func orbHelpLong(config *settings.Config) string {
See a full explanation and documentation on orbs here: %s`, config.Data.Links.OrbDocs)
}
+// Transform a boolean parameter into a string. Because the value can be a boolean but can also be
+// a string, we need to first parse it as a boolean and then if it is not a boolean, parse it as
+// a string
+//
+// Documentation reference: https://circleci.com/docs/reusing-config/#boolean
+func booleanParameterDefaultToString(parameter api.OrbElementParameter) string {
+ if v, ok := parameter.Default.(bool); ok {
+ return fmt.Sprintf("%t", v)
+ }
+ v, ok := parameter.Default.(string)
+ if !ok {
+ log.Panicf("Unable to parse boolean parameter with value %+v", v)
+ }
+ return v
+}
+
func parameterDefaultToString(parameter api.OrbElementParameter) string {
defaultValue := " (default: '"
@@ -379,12 +417,18 @@ func parameterDefaultToString(parameter api.OrbElementParameter) string {
}
switch parameter.Type {
- case "enum":
- defaultValue += parameter.Default.(string)
- case "string":
- defaultValue += parameter.Default.(string)
+ case "enum", "string":
+ if v, ok := parameter.Default.(string); ok {
+ defaultValue += v
+ break
+ }
+ if v, ok := parameter.Default.(fmt.Stringer); ok {
+ defaultValue += v.String()
+ break
+ }
+ log.Panicf("Unable to parse parameter default with value %+v because it's neither a string nor a stringer", parameter.Default)
case "boolean":
- defaultValue += fmt.Sprintf("%t", parameter.Default.(bool))
+ defaultValue += booleanParameterDefaultToString(parameter)
default:
defaultValue += ""
}
@@ -589,12 +633,20 @@ var validSortFlag = map[string]bool{
"projects": true,
"orgs": true}
-func validateSortFlag(sort string) error {
- if _, valid := validSortFlag[sort]; valid {
+func validateSortFlag(sortFlag string) error {
+ if _, valid := validSortFlag[sortFlag]; valid {
return nil
}
- // TODO(zzak): we could probably reuse the map above to print the valid values
- return fmt.Errorf("expected `%s` to be one of \"builds\", \"projects\", or \"orgs\"", sort)
+
+ keys := make([]string, 0, len(validSortFlag))
+ for key := range validSortFlag {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+
+ validFlags := fmt.Sprint(strings.Join(keys, ", "))
+
+ return fmt.Errorf("expected `%s` to be one of: %s", sortFlag, validFlags)
}
func listOrbs(opts orbOptions) error {
@@ -627,7 +679,7 @@ func listOrbs(opts orbOptions) error {
func listNamespaceOrbs(opts orbOptions) error {
namespace := opts.args[0]
- orbs, err := api.ListNamespaceOrbs(opts.cl, namespace, opts.private)
+ orbs, err := api.ListNamespaceOrbs(opts.cl, namespace, opts.private, opts.listDetails)
if err != nil {
return errors.Wrapf(err, "Failed to list orbs in namespace `%s`", namespace)
}
@@ -818,6 +870,16 @@ If you change your mind about the name, you will have to create a new orb with t
`, namespace, orbName)
}
+ if opts.private {
+ fmt.Printf(`This orb will not be listed on the registry and is usable only by org users.
+
+`)
+ } else {
+ fmt.Printf(`Please note that any versions you publish of this orb will be world readable unless you create it with the '--private' flag
+
+`)
+ }
+
confirm := fmt.Sprintf("Are you sure you wish to create the orb: `%s/%s`", namespace, orbName)
if opts.noPrompt || opts.tty.askUserToConfirm(confirm) {
@@ -827,13 +889,7 @@ If you change your mind about the name, you will have to create a new orb with t
return err
}
- confirmationString := "Please note that any versions you publish of this orb are world-readable."
- if opts.private {
- confirmationString = "This orb will not be listed on the registry and is usable only by org users."
- }
-
fmt.Printf("Orb `%s` created.\n", opts.args[0])
- fmt.Println(confirmationString)
fmt.Printf("You can now register versions of `%s` using `circleci orb publish`.\n", opts.args[0])
}
@@ -1060,12 +1116,12 @@ func initOrb(opts orbOptions) error {
fmt.Printf("Downloading Orb Project Template into %s\n", orbPath)
httpClient := http.Client{}
- req, err := httpClient.Get("https://api.github.com/repos/CircleCI-Public/Orb-Project-Template/tags")
+ req, err := httpClient.Get("https://api.github.com/repos/CircleCI-Public/Orb-Template/tags")
if err != nil {
return errors.Wrap(err, "Unexpected error")
}
- body, err := ioutil.ReadAll(req.Body)
+ body, err := io.ReadAll(req.Body)
if err != nil {
return errors.Wrap(err, "Unexpected error")
}
@@ -1075,15 +1131,25 @@ func initOrb(opts orbOptions) error {
return errors.Wrap(err, "Unexpected error")
}
- latestTag := tags[0].ZipUrl
- resp, err := http.Get(latestTag)
+ // filter out any non-release tags
+ releaseTags := []orbProtectTemplateRelease{}
+ validTagRegex := regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
+ for _, tag := range tags {
+ matched := validTagRegex.MatchString(tag.Name)
+ if matched {
+ releaseTags = append(releaseTags, tag)
+ }
+ }
+
+ latestRelease := releaseTags[0]
+ resp, err := http.Get(latestRelease.ZipUrl)
if err != nil {
return err
}
defer resp.Body.Close()
// Create the file
- out, err := os.Create(filepath.Join(os.TempDir(), "orb-project-template.zip"))
+ out, err := os.Create(filepath.Join(os.TempDir(), "orb-template.zip"))
if err != nil {
return err
}
@@ -1095,7 +1161,7 @@ func initOrb(opts orbOptions) error {
return err
}
- err = unzipToOrbPath(filepath.Join(os.TempDir(), "orb-project-template.zip"), orbPath)
+ err = unzipToOrbPath(filepath.Join(os.TempDir(), "orb-template.zip"), orbPath)
if err != nil {
return err
}
@@ -1177,11 +1243,30 @@ func initOrb(opts orbOptions) error {
Message: "Orb name",
Default: orbName,
}
+
+ orbExists := true
+
err = survey.AskOne(iprompt, &orbName)
if err != nil {
return errors.Wrap(err, "Unexpected error")
}
+ _, err = api.OrbInfo(opts.cl, namespace+"/"+orbName)
+ if err != nil {
+ orbExists = false
+ }
+
+ if orbExists {
+ mprompt := &survey.Confirm{
+ Message: fmt.Sprintf("Orb %s/%s already exists, would you like to continue?", namespace, orbName),
+ }
+ confirmation := false
+ err = survey.AskOne(mprompt, &confirmation)
+ if err != nil {
+ return errors.Wrap(err, "Orb already exists")
+ }
+ }
+
registryCategories, err := api.ListOrbCategories(opts.cl)
if err != nil {
return errors.Wrapf(err, "Failed to list orb categories")
@@ -1254,9 +1339,11 @@ func initOrb(opts orbOptions) error {
}()
if !gitAction {
- _, err = api.CreateOrb(opts.cl, namespace, orbName, opts.private)
- if err != nil {
- return errors.Wrap(err, "Unable to create orb")
+ if !orbExists {
+ _, err = api.CreateOrb(opts.cl, namespace, orbName, opts.private)
+ if err != nil {
+ return errors.Wrap(err, "Unable to create orb")
+ }
}
for _, v := range categories {
err = api.AddOrRemoveOrbCategorization(opts.cl, namespace, orbName, v, api.Add)
@@ -1297,23 +1384,46 @@ func initOrb(opts orbOptions) error {
return y[0]
}()
- circleConfig, err := ioutil.ReadFile(path.Join(orbPath, ".circleci", "config.yml"))
+ circleConfigSetup, err := os.ReadFile(path.Join(orbPath, ".circleci", "config.yml"))
if err != nil {
return err
}
- circle := string(circleConfig)
- err = ioutil.WriteFile(path.Join(orbPath, ".circleci", "config.yml"), []byte(orbTemplate(circle, projectName, ownerName, orbName, namespace)), 0644)
+ configSetupString := string(circleConfigSetup)
+ err = os.WriteFile(path.Join(orbPath, ".circleci", "config.yml"), []byte(orbTemplate(configSetupString, projectName, ownerName, orbName, namespace)), 0644)
if err != nil {
return err
}
- readme, err := ioutil.ReadFile(path.Join(orbPath, "README.md"))
+ circleConfigDeploy, err := os.ReadFile(path.Join(orbPath, ".circleci", "test-deploy.yml"))
if err != nil {
return err
}
+
+ configDeployString := string(circleConfigDeploy)
+ err = os.WriteFile(path.Join(orbPath, ".circleci", "test-deploy.yml"), []byte(orbTemplate(configDeployString, projectName, ownerName, orbName, namespace)), 0644)
+ if err != nil {
+ return err
+ }
+
+ readme, err := os.ReadFile(path.Join(orbPath, "README.md"))
+ if err != nil {
+ return err
+ }
+
readmeString := string(readme)
- err = ioutil.WriteFile(path.Join(orbPath, "README.md"), []byte(orbTemplate(readmeString, projectName, ownerName, orbName, namespace)), 0644)
+ err = os.WriteFile(path.Join(orbPath, "README.md"), []byte(orbTemplate(readmeString, projectName, ownerName, orbName, namespace)), 0644)
+ if err != nil {
+ return err
+ }
+
+ orbRoot, err := os.ReadFile(path.Join(orbPath, "src", "@orb.yml"))
+ if err != nil {
+ return err
+ }
+
+ orbRootString := string(orbRoot)
+ err = os.WriteFile(path.Join(orbPath, "src", "@orb.yml"), []byte(orbTemplate(orbRootString, projectName, ownerName, orbName, namespace)), 0644)
if err != nil {
return err
}
@@ -1349,13 +1459,13 @@ func initOrb(opts orbOptions) error {
}
if version.PackageManager() != "snap" {
- _, err = w.Commit("[semver:skip] Initial commit.", &git.CommitOptions{})
+ _, err = w.Commit("feat: Initial commit.", &git.CommitOptions{})
if err != nil {
return err
}
} else {
fmt.Println("We detected you installed the CLI via snap\nThe commit generated will not match your actual git username or email due to sandboxing.")
- _, err = w.Commit("[semver:skip] Initial commit.", &git.CommitOptions{
+ _, err = w.Commit("feat: Initial commit.", &git.CommitOptions{
Author: &object.Signature{
Name: "CircleCI",
Email: "community-partner@circleci.com",
@@ -1368,9 +1478,11 @@ func initOrb(opts orbOptions) error {
}
// Push a dev version of the orb.
- _, err = api.CreateOrb(opts.cl, namespace, orbName, opts.private)
- if err != nil {
- return errors.Wrap(err, "Unable to create orb")
+ if !orbExists {
+ _, err = api.CreateOrb(opts.cl, namespace, orbName, opts.private)
+ if err != nil {
+ return errors.Wrap(err, "Unable to create orb")
+ }
}
for _, v := range categories {
err = api.AddOrRemoveOrbCategorization(opts.cl, namespace, orbName, v, api.Add)
@@ -1391,7 +1503,7 @@ func initOrb(opts orbOptions) error {
}
tempOrbFile := filepath.Join(tempOrbDir, "orb.yml")
- err = ioutil.WriteFile(tempOrbFile, []byte(packedOrb), 0644)
+ err = os.WriteFile(tempOrbFile, []byte(packedOrb), 0644)
if err != nil {
return errors.Wrap(err, "Unable to write packed orb")
}
@@ -1401,9 +1513,11 @@ func initOrb(opts orbOptions) error {
return err
}
- fmt.Printf("An initial commit has been created - please run \033[1;34m'git push origin %v'\033[0m to publish your first commit!\n", gitBranch)
+ fmt.Printf("An initial commit has been created - please run the following commands in a separate terminal window. \n")
+ fmt.Printf("\033[1;34m'git branch -M %v'\033[0m\n", gitBranch)
+ fmt.Printf("\033[1;34m'git push origin %v'\033[0m\n", gitBranch)
yprompt = &survey.Confirm{
- Message: "I have pushed to my git repository using the above command",
+ Message: "I have pushed to my git repository using the above commands",
}
// We don't use this anywhere, but AskOne will fail if we don't give it a
// place to put the result.
@@ -1484,7 +1598,7 @@ func unzipToOrbPath(src, dest string) error {
}
}()
- // This is neccesary because the zip downloaded from GitHub will have a
+ // This is necessary because the zip downloaded from GitHub will have a
// directory with the actual template, rather than the template being
// top-level.
pathParts := strings.Split(f.Name, "/")
@@ -1542,3 +1656,81 @@ func orbTemplate(fileContents string, projectName string, orgName string, orbNam
return x
}
+
+func orbDiff(opts orbOptions) error {
+ colorOpt := opts.color
+ allowedColorOpts := []string{"auto", "always", "never"}
+ if !slices.Contains(allowedColorOpts, colorOpt) {
+ return fmt.Errorf("option `color' expects \"always\", \"auto\", or \"never\"")
+ }
+
+ orbName := opts.args[0]
+ version1 := opts.args[1]
+ version2 := opts.args[2]
+ orb1 := fmt.Sprintf("%s@%s", orbName, version1)
+ orb2 := fmt.Sprintf("%s@%s", orbName, version2)
+
+ orb1Source, err := api.OrbSource(opts.cl, orb1)
+ if err != nil {
+ return errors.Wrapf(err, "Failed to get source for '%s'", orb1)
+ }
+ orb2Source, err := api.OrbSource(opts.cl, orb2)
+ if err != nil {
+ return errors.Wrapf(err, "Failed to get source for '%s'", orb2)
+ }
+
+ edits := myers.ComputeEdits(span.URIFromPath(orb1), orb1Source, orb2Source)
+ unified := gotextdiff.ToUnified(orb1, orb2, orb1Source, edits)
+ diff := stringifyDiff(unified, colorOpt)
+ if diff == "" {
+ fmt.Println("No diff found")
+ } else {
+ fmt.Println(diff)
+ }
+
+ return nil
+}
+
+// Stringifies the unified diff passed as argument, and colorize it depending on the colorOpt value
+func stringifyDiff(diff gotextdiff.Unified, colorOpt string) string {
+ if len(diff.Hunks) == 0 {
+ return ""
+ }
+
+ headerColor := color.New(color.BgYellow, color.FgBlack)
+ diffStartColor := color.New(color.BgBlue, color.FgWhite)
+ deleteColor := color.New(color.FgRed)
+ insertColor := color.New(color.FgGreen)
+ untouchedColor := color.New(color.Reset)
+
+ // The color library already takes care of disabling the color when stdout is redirected so we
+ // just enforce the color behavior for "never" and "always" and let the library handle the 'auto'
+ // case
+ oldNoColor := color.NoColor
+ if colorOpt == "never" {
+ color.NoColor = true
+ }
+ if colorOpt == "always" {
+ color.NoColor = false
+ }
+
+ diffString := fmt.Sprintf("%s", diff)
+ lines := strings.Split(diffString, "\n")
+
+ for i, line := range lines {
+ if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
+ lines[i] = headerColor.Sprint(line)
+ } else if strings.HasPrefix(line, "@@ ") {
+ lines[i] = diffStartColor.Sprint(line)
+ } else if strings.HasPrefix(line, "-") {
+ lines[i] = deleteColor.Sprint(line)
+ } else if strings.HasPrefix(line, "+") {
+ lines[i] = insertColor.Sprint(line)
+ } else {
+ lines[i] = untouchedColor.Sprint(line)
+ }
+ }
+
+ color.NoColor = oldNoColor
+ return strings.Join(lines, "\n")
+}
diff --git a/cmd/orb_import.go b/cmd/orb_import.go
index 5ddb18168..fca5512b9 100644
--- a/cmd/orb_import.go
+++ b/cmd/orb_import.go
@@ -216,7 +216,7 @@ func deleteNamespace(nsOpts namespaceOptions) error {
// Currently, private orbs will not be included in the list of orbs to be deleted.
// This can be changed once we have 'listBothPublicAndPrivateOrbs' functionality.
- orbs, err := api.ListNamespaceOrbs(nsOpts.cl, namespaceArg, false)
+ orbs, err := api.ListNamespaceOrbs(nsOpts.cl, namespaceArg, false, false)
if err != nil {
return fmt.Errorf("unable to list orbs: %s", err.Error())
}
diff --git a/cmd/orb_import_test.go b/cmd/orb_import_test.go
index 86ce6c10e..70412be03 100644
--- a/cmd/orb_import_test.go
+++ b/cmd/orb_import_test.go
@@ -3,7 +3,7 @@ package cmd
import (
"bytes"
"fmt"
- "io/ioutil"
+ "io"
"net/http"
"github.com/CircleCI-Public/circleci-cli/api"
@@ -678,9 +678,9 @@ The following orb versions already exist:
('namespace1/orb@0.0.3')
`
- actual, err := ioutil.ReadAll(&b)
+ actual, err := io.ReadAll(&b)
Expect(err).ShouldNot(HaveOccurred())
- Expect(fmt.Sprintf("%s", actual)).To(Equal(expOutput))
+ Expect(string(actual)).To(Equal(expOutput))
})
})
diff --git a/cmd/orb_test.go b/cmd/orb_test.go
index 8d0b89cb6..e5a5f42b3 100644
--- a/cmd/orb_test.go
+++ b/cmd/orb_test.go
@@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"strconv"
+ "time"
"gotest.tools/v3/golden"
@@ -1166,8 +1167,9 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs
Eventually(session).Should(gexec.Exit(0))
stdout := session.Wait().Out.Contents()
- Expect(string(stdout)).To(ContainSubstring(fmt.Sprintf(`Orb %s created.
-Please note that any versions you publish of this orb are world-readable.
+ Expect(string(stdout)).To(ContainSubstring(fmt.Sprintf(`Please note that any versions you publish of this orb will be world readable unless you create it with the '--private' flag
+
+Orb %s created.
You can now register versions of %s using %s`, "`bar-ns/foo-orb`", "`bar-ns/foo-orb`", "`circleci orb publish`")))
})
@@ -1231,8 +1233,9 @@ You can now register versions of %s using %s`, "`bar-ns/foo-orb`", "`bar-ns/foo-
Eventually(session).Should(gexec.Exit(0))
stdout := session.Wait().Out.Contents()
- Expect(string(stdout)).To(ContainSubstring(fmt.Sprintf(`Orb %s created.
-This orb will not be listed on the registry and is usable only by org users.
+ Expect(string(stdout)).To(ContainSubstring(fmt.Sprintf(`This orb will not be listed on the registry and is usable only by org users.
+
+Orb %s created.
You can now register versions of %s using %s`, "`bar-ns/foo-orb`", "`bar-ns/foo-orb`", "`circleci orb publish`")))
})
@@ -1364,9 +1367,10 @@ You will not be able to change the name of this orb.
If you change your mind about the name, you will have to create a new orb with the new name.
+Please note that any versions you publish of this orb will be world readable unless you create it with the '--private' flag
+
Are you sure you wish to create the orb: %s
Orb %s created.
-Please note that any versions you publish of this orb are world-readable.
You can now register versions of %s using %s.`,
"bar-ns/foo-orb", "`bar-ns/foo-orb`", "`bar-ns/foo-orb`", "`bar-ns/foo-orb`", "`circleci orb publish`")))
})
@@ -1979,7 +1983,7 @@ Search, filter, and view sources for all Orbs online at https://circleci.com/dev
Eventually(session).Should(clitest.ShouldFail())
stderr := session.Wait().Err.Contents()
- Expect(string(stderr)).To(Equal("Error: expected `idontknow` to be one of \"builds\", \"projects\", or \"orgs\"\n"))
+ Expect(string(stderr)).To(Equal("Error: expected `idontknow` to be one of: builds, orgs, projects\n"))
})
})
@@ -2288,6 +2292,7 @@ Search, filter, and view sources for all Orbs online at https://circleci.com/dev
tmpBytes := golden.Get(GinkgoT(), filepath.FromSlash("gql_orb_list_details/pretty_json_output.json"))
expectedOutput := string(tmpBytes)
completeOutput := string(session.Wait().Out.Contents())
+
Expect(completeOutput).Should(MatchJSON(expectedOutput))
Expect(tempSettings.TestServer.ReceivedRequests()).Should(HaveLen(1))
})
@@ -2313,9 +2318,7 @@ query namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType
edges {
cursor
node {
- versions {
- source
- version
+ versions (count: 1){ source, version
}
name
statistics {
@@ -2390,6 +2393,7 @@ query namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType
"orb", "list", "circleci",
"--skip-update-check",
"--host", tempSettings.TestServer.URL(),
+ "--details",
"--json",
)
})
@@ -2429,9 +2433,7 @@ query namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType
edges {
cursor
node {
- versions {
- source
- version
+ versions { version
}
name
statistics {
@@ -2491,9 +2493,7 @@ query namespaceOrbs ($namespace: String, $after: String!, $view: OrbListViewType
edges {
cursor
node {
- versions {
- source
- version
+ versions { version
}
name
statistics {
@@ -3342,4 +3342,91 @@ Windows Server 2010
})
})
+
+ Describe("Orb diff", func() {
+ var (
+ token string
+ tempSettings *clitest.TempSettings
+ command *exec.Cmd
+ )
+
+ BeforeEach(func() {
+ token = "testtoken"
+ tempSettings = clitest.WithTempSettings()
+ })
+
+ AfterEach(func() {
+ tempSettings.Close()
+ })
+
+ DescribeTable("Shows the expected diff", func(source1, source2, expected, color string) {
+ orbName := "somenamespace/someorb"
+ version1 := "1.0.0"
+ orb1 := fmt.Sprintf("%s@%s", orbName, version1)
+ version2 := "2.0.0"
+ orb2 := fmt.Sprintf("%s@%s", orbName, version2)
+ command = exec.Command(pathCLI, "orb", "diff", orbName, version1, version2,
+ "--token", token,
+ "--host", tempSettings.TestServer.URL())
+
+ mockOrbSource(source1, orb1, token, tempSettings)
+ mockOrbSource(source2, orb2, token, tempSettings)
+
+ session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
+ Expect(err).ShouldNot(HaveOccurred())
+
+ Eventually(session.Out).WithTimeout(5 * time.Second).Should(gbytes.Say(expected))
+ Eventually(session).Should(gexec.Exit(0))
+ },
+ Entry("Detect identical sources", "orb-source", "orb-source", "No diff found", "auto"),
+ Entry(
+ "Detect difference",
+ "line1\\nline3\\n",
+ "line1\\nline2\\n",
+ `--- somenamespace/someorb@1.0.0
+\+\+\+ somenamespace/someorb@2.0.0
+@@ -1,2 \+1,2 @@
+ line1
+-line3
+\+line2`,
+ "auto",
+ ),
+ )
+ })
})
+
+func mockOrbSource(source, orbVersion, token string, tempSettings *clitest.TempSettings) {
+ requestStruct := struct {
+ Query string `json:"query"`
+ Variables struct {
+ OrbVersionRef string `json:"orbVersionRef"`
+ } `json:"variables"`
+ }{
+ Query: `query($orbVersionRef: String!) {
+ orbVersion(orbVersionRef: $orbVersionRef) {
+ id
+ version
+ orb { id }
+ source
+ }
+ }`,
+ Variables: struct {
+ OrbVersionRef string `json:"orbVersionRef"`
+ }{OrbVersionRef: orbVersion},
+ }
+ request, err := json.Marshal(requestStruct)
+ Expect(err).ToNot(HaveOccurred())
+ response := fmt.Sprintf(`{
+ "orbVersion": {
+ "id": "some-id",
+ "version": "some-version",
+ "orb": { "id": "some-id" },
+ "source": "%s"
+ }
+}`, source)
+ tempSettings.AppendPostHandler(token, clitest.MockRequestResponse{
+ Status: http.StatusOK,
+ Request: string(request),
+ Response: response,
+ })
+}
diff --git a/cmd/orb_unit_test.go b/cmd/orb_unit_test.go
new file mode 100644
index 000000000..378d5650d
--- /dev/null
+++ b/cmd/orb_unit_test.go
@@ -0,0 +1,66 @@
+package cmd
+
+import (
+ "time"
+
+ "github.com/CircleCI-Public/circleci-cli/api"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/ginkgo/extensions/table"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Orb unit tests", func() {
+ Describe("Orb formatters", func() {
+ DescribeTable(
+ "parameterDefaultToString",
+ func(input api.OrbElementParameter, expected string) {
+ Expect(parameterDefaultToString(input)).To(Equal(expected))
+ },
+ Entry(
+ "Normal behaviour for string",
+ api.OrbElementParameter{
+ Type: "string",
+ Description: "",
+ Default: "Normal behavior",
+ },
+ " (default: 'Normal behavior')",
+ ),
+ Entry(
+ "Normal behaviour for enum",
+ api.OrbElementParameter{
+ Type: "enum",
+ Description: "",
+ Default: "Normal behavior",
+ },
+ " (default: 'Normal behavior')",
+ ),
+ Entry(
+ "Normal behaviour for boolean",
+ api.OrbElementParameter{
+ Type: "boolean",
+ Description: "",
+ Default: true,
+ },
+ " (default: 'true')",
+ ),
+ Entry(
+ "String value for boolean",
+ api.OrbElementParameter{
+ Type: "boolean",
+ Description: "",
+ Default: "yes",
+ },
+ " (default: 'yes')",
+ ),
+ Entry(
+ "Time value for string",
+ api.OrbElementParameter{
+ Type: "string",
+ Description: "",
+ Default: time.Date(2023, 02, 20, 11, 9, 0, 0, time.Now().UTC().Location()),
+ },
+ " (default: '2023-02-20 11:09:00 +0000 UTC')",
+ ),
+ )
+ })
+})
diff --git a/cmd/policy/policy.go b/cmd/policy/policy.go
new file mode 100644
index 000000000..f809a4571
--- /dev/null
+++ b/cmd/policy/policy.go
@@ -0,0 +1,646 @@
+package policy
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/CircleCI-Public/circle-policy-agent/cpa"
+ "github.com/CircleCI-Public/circle-policy-agent/cpa/tester"
+
+ "github.com/araddon/dateparse"
+ "github.com/briandowns/spinner"
+ "github.com/spf13/cobra"
+ "gopkg.in/yaml.v3"
+
+ "github.com/CircleCI-Public/circleci-cli/api/policy"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+)
+
+// NewCommand creates the root policy command with all policy subcommands attached.
+func NewCommand(config *settings.Config, preRunE validator.Validator) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "policy",
+ PersistentPreRunE: preRunE,
+ Short: "Manage security policies",
+ Long: `Policies ensures security of build configs via security policy management framework.
+This group of commands allows the management of polices to be verified against build configs.`,
+ }
+
+ policyBaseURL := cmd.PersistentFlags().String("policy-base-url", "https://internal.circleci.com", "base url for policy api")
+
+ push := func() *cobra.Command {
+ var ownerID, context string
+ var noPrompt bool
+ var request policy.CreatePolicyBundleRequest
+
+ cmd := &cobra.Command{
+ Short: "push policy bundle",
+ Use: "push ",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ bundle, err := loadBundleFromFS(args[0])
+ if err != nil {
+ return fmt.Errorf("failed to walk policy directory path: %w", err)
+ }
+
+ request.Policies = bundle
+
+ client := policy.NewClient(*policyBaseURL, config)
+
+ if !noPrompt {
+ request.DryRun = true
+ diff, err := client.CreatePolicyBundle(ownerID, context, request)
+ if err != nil {
+ return fmt.Errorf("failed to get bundle diff: %v", err)
+ }
+
+ _, _ = io.WriteString(cmd.ErrOrStderr(), "The following changes are going to be made: ")
+ _ = prettyJSONEncoder(cmd.ErrOrStderr()).Encode(diff)
+ _, _ = io.WriteString(cmd.ErrOrStderr(), "\n")
+
+ proceed, err := Confirm(cmd.ErrOrStderr(), cmd.InOrStdin(), "Do you wish to continue? (y/N)")
+ if err != nil {
+ return err
+ }
+ if !proceed {
+ return nil
+ }
+ _, _ = io.WriteString(cmd.ErrOrStderr(), "\n")
+ }
+
+ request.DryRun = false
+
+ diff, err := client.CreatePolicyBundle(ownerID, context, request)
+ if err != nil {
+ return fmt.Errorf("failed to push policy bundle: %w", err)
+ }
+
+ _, _ = io.WriteString(cmd.ErrOrStderr(), "Policy Bundle Pushed Successfully\n")
+ _, _ = io.WriteString(cmd.ErrOrStderr(), "\ndiff: ")
+ _ = prettyJSONEncoder(cmd.OutOrStdout()).Encode(diff)
+
+ return nil
+ },
+ Args: cobra.ExactArgs(1),
+ Example: `circleci policy push ./policies --owner-id 462d67f8-b232-4da4-a7de-0c86dd667d3f`,
+ }
+
+ cmd.Flags().StringVar(&context, "context", "config", "policy context")
+ cmd.Flags().StringVar(&ownerID, "owner-id", "", "the id of the policy's owner")
+ cmd.Flags().BoolVar(&noPrompt, "no-prompt", false, "removes the prompt")
+ if err := cmd.MarkFlagRequired("owner-id"); err != nil {
+ panic(err)
+ }
+
+ return cmd
+ }()
+
+ diff := func() *cobra.Command {
+ var ownerID, context string
+ cmd := &cobra.Command{
+ Short: "Get diff between local and remote policy bundles",
+ Use: "diff ",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ bundle, err := loadBundleFromFS(args[0])
+ if err != nil {
+ return fmt.Errorf("failed to walk policy directory path: %w", err)
+ }
+
+ diff, err := policy.NewClient(*policyBaseURL, config).CreatePolicyBundle(ownerID, context, policy.CreatePolicyBundleRequest{
+ Policies: bundle,
+ DryRun: true,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to get diff: %w", err)
+ }
+
+ return prettyJSONEncoder(cmd.OutOrStdout()).Encode(diff)
+ },
+ Args: cobra.ExactArgs(1),
+ Example: `circleci policy diff ./policies --owner-id 462d67f8-b232-4da4-a7de-0c86dd667d3f`,
+ }
+ cmd.Flags().StringVar(&context, "context", "config", "policy context")
+ cmd.Flags().StringVar(&ownerID, "owner-id", "", "the id of the policy's owner")
+ if err := cmd.MarkFlagRequired("owner-id"); err != nil {
+ panic(err)
+ }
+
+ return cmd
+ }()
+
+ fetch := func() *cobra.Command {
+ var ownerID, context, policyName string
+ cmd := &cobra.Command{
+ Short: "Fetch policy bundle (or a single policy)",
+ Use: "fetch [policy_name]",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 1 {
+ policyName = args[0]
+ }
+ policies, err := policy.NewClient(*policyBaseURL, config).FetchPolicyBundle(ownerID, context, policyName)
+ if err != nil {
+ return fmt.Errorf("failed to fetch policy bundle: %v", err)
+ }
+
+ if err := prettyJSONEncoder(cmd.OutOrStdout()).Encode(policies); err != nil {
+ return fmt.Errorf("failed to output policy bundle in json format: %v", err)
+ }
+
+ return nil
+ },
+ Args: cobra.MaximumNArgs(1),
+ Example: `circleci policy fetch --owner-id 516425b2-e369-421b-838d-920e1f51b0f5`,
+ }
+
+ cmd.Flags().StringVar(&context, "context", "config", "policy context")
+ cmd.Flags().StringVar(&ownerID, "owner-id", "", "the id of the policy's owner")
+ if err := cmd.MarkFlagRequired("owner-id"); err != nil {
+ panic(err)
+ }
+
+ return cmd
+ }()
+
+ logs := func() *cobra.Command {
+ var after, before, outputFile, ownerID, context, decisionID string
+ var policyBundle bool
+ var request policy.DecisionQueryRequest
+
+ cmd := &cobra.Command{
+ Short: "Get policy decision logs / Get decision log (or policy bundle) by decision ID",
+ Use: "logs [decision_id]",
+ RunE: func(cmd *cobra.Command, args []string) (err error) {
+ if len(args) == 1 {
+ decisionID = args[0]
+ }
+ if decisionID != "" && (after != "" || before != "" || request.Status != "" || request.Offset != 0 || request.Branch != "" || request.ProjectID != "") {
+ return fmt.Errorf("filters are not accepted when decision_id is provided")
+ }
+ if policyBundle && decisionID == "" {
+ return fmt.Errorf("decision_id is required when --policy-bundle flag is used")
+ }
+ if cmd.Flag("after").Changed {
+ request.After = new(time.Time)
+ *request.After, err = dateparse.ParseStrict(after)
+ if err != nil {
+ return fmt.Errorf("error in parsing --after value: %v", err)
+ }
+ }
+
+ if cmd.Flag("before").Changed {
+ request.Before = new(time.Time)
+ *request.Before, err = dateparse.ParseStrict(before)
+ if err != nil {
+ return fmt.Errorf("error in parsing --before value: %v", err)
+ }
+ }
+
+ dst := cmd.OutOrStdout()
+ if outputFile != "" {
+ file, err := os.Create(outputFile)
+ if err != nil {
+ return fmt.Errorf("failed to create output file: %v", err)
+ }
+ dst = file
+ defer func() {
+ if closeErr := file.Close(); err == nil && closeErr != nil {
+ err = closeErr
+ }
+ }()
+ }
+
+ client := policy.NewClient(*policyBaseURL, config)
+
+ output, err := func() (interface{}, error) {
+ if decisionID != "" {
+ return client.GetDecisionLog(ownerID, context, decisionID, policyBundle)
+ }
+ return getAllDecisionLogs(client, ownerID, context, request, cmd.ErrOrStderr())
+ }()
+ if err != nil {
+ return fmt.Errorf("failed to get policy decision logs: %v", err)
+ }
+
+ if err := prettyJSONEncoder(dst).Encode(output); err != nil {
+ return fmt.Errorf("failed to output policy decision logs in json format: %v", err)
+ }
+
+ return nil
+ },
+ Args: cobra.MaximumNArgs(1),
+ Example: `circleci policy logs --owner-id 462d67f8-b232-4da4-a7de-0c86dd667d3f --after 2022/03/14 --out output.json`,
+ }
+
+ cmd.Flags().StringVar(&request.Status, "status", "", "filter decision logs based on their status")
+ cmd.Flags().StringVar(&after, "after", "", "filter decision logs triggered AFTER this datetime")
+ cmd.Flags().StringVar(&before, "before", "", "filter decision logs triggered BEFORE this datetime")
+ cmd.Flags().StringVar(&request.Branch, "branch", "", "filter decision logs based on branch name")
+ cmd.Flags().StringVar(&request.ProjectID, "project-id", "", "filter decision logs based on project-id")
+ cmd.Flags().StringVar(&outputFile, "out", "", "specify output file name ")
+ cmd.Flags().BoolVar(&policyBundle, "policy-bundle", false, "get only the policy bundle for given decisionID")
+ cmd.Flags().StringVar(&context, "context", "config", "policy context")
+ cmd.Flags().StringVar(&ownerID, "owner-id", "", "the id of the policy's owner")
+ if err := cmd.MarkFlagRequired("owner-id"); err != nil {
+ panic(err)
+ }
+
+ return cmd
+ }()
+
+ decide := func() *cobra.Command {
+ var (
+ inputPath string
+ policyPath string
+ meta string
+ metaFile string
+ ownerID string
+ context string
+ strict bool
+ request policy.DecisionRequest
+ )
+
+ cmd := &cobra.Command{
+ Short: "make a decision",
+ Use: "decide [policy_file_or_dir_path]",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 1 {
+ policyPath = args[0]
+ }
+ if (policyPath == "" && ownerID == "") || (policyPath != "" && ownerID != "") {
+ return fmt.Errorf("either [policy_file_or_dir_path] or --owner-id is required")
+ }
+
+ input, err := os.ReadFile(inputPath)
+ if err != nil {
+ return fmt.Errorf("failed to read input file: %w", err)
+ }
+
+ metadata, err := readMetadata(meta, metaFile)
+ if err != nil {
+ return fmt.Errorf("failed to read metadata: %w", err)
+ }
+
+ decision, err := func() (*cpa.Decision, error) {
+ if policyPath != "" {
+ return getPolicyDecisionLocally(policyPath, input, metadata)
+ }
+ request.Input = string(input)
+ request.Metadata = metadata
+ return policy.NewClient(*policyBaseURL, config).MakeDecision(ownerID, context, request)
+ }()
+ if err != nil {
+ return fmt.Errorf("failed to make decision: %w", err)
+ }
+
+ if strict && (decision.Status == cpa.StatusHardFail || decision.Status == cpa.StatusError) {
+ return fmt.Errorf("policy decision status: %s", decision.Status)
+ }
+
+ if err := prettyJSONEncoder(cmd.OutOrStdout()).Encode(decision); err != nil {
+ return fmt.Errorf("failed to encode decision: %w", err)
+ }
+
+ return nil
+ },
+ Args: cobra.MaximumNArgs(1),
+ Example: `circleci policy decide ./policies --input ./.circleci/config.yml`,
+ }
+
+ cmd.Flags().StringVar(&ownerID, "owner-id", "", "the id of the policy's owner")
+ cmd.Flags().StringVar(&context, "context", "config", "policy context for decision")
+ cmd.Flags().StringVar(&inputPath, "input", "", "path to input file")
+ cmd.Flags().StringVar(&meta, "meta", "", "decision metadata (json string)")
+ cmd.Flags().StringVar(&metaFile, "metafile", "", "decision metadata file")
+ cmd.Flags().BoolVar(&strict, "strict", false, "return non-zero status code for decision resulting in HARD_FAIL")
+
+ if err := cmd.MarkFlagRequired("input"); err != nil {
+ panic(err)
+ }
+
+ return cmd
+ }()
+
+ eval := func() *cobra.Command {
+ var inputPath, meta, metaFile, query string
+ cmd := &cobra.Command{
+ Short: "perform raw opa evaluation locally",
+ Use: "eval ",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ policyPath := args[0]
+ input, err := os.ReadFile(inputPath)
+ if err != nil {
+ return fmt.Errorf("failed to read input file: %w", err)
+ }
+
+ metadata, err := readMetadata(meta, metaFile)
+ if err != nil {
+ return fmt.Errorf("failed to read metadata: %w", err)
+ }
+
+ decision, err := getPolicyEvaluationLocally(policyPath, input, metadata, query)
+ if err != nil {
+ return fmt.Errorf("failed to make decision: %w", err)
+ }
+
+ if err := prettyJSONEncoder(cmd.OutOrStdout()).Encode(decision); err != nil {
+ return fmt.Errorf("failed to encode decision: %w", err)
+ }
+
+ return nil
+ },
+ Args: cobra.ExactArgs(1),
+ Example: `circleci policy eval ./policies --input ./.circleci/config.yml`,
+ }
+
+ cmd.Flags().StringVar(&inputPath, "input", "", "path to input file")
+ cmd.Flags().StringVar(&meta, "meta", "", "decision metadata (json string)")
+ cmd.Flags().StringVar(&metaFile, "metafile", "", "decision metadata file")
+ cmd.Flags().StringVar(&query, "query", "data", "policy decision query")
+
+ if err := cmd.MarkFlagRequired("input"); err != nil {
+ panic(err)
+ }
+
+ return cmd
+ }()
+
+ settings := func() *cobra.Command {
+ var (
+ ownerID string
+ context string
+ enabled bool
+ request policy.DecisionSettings
+ )
+
+ cmd := &cobra.Command{
+ Short: "get/set policy decision settings (To read settings: run command without any settings flags)",
+ Use: "settings",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ client := policy.NewClient(*policyBaseURL, config)
+
+ response, err := func() (interface{}, error) {
+ if cmd.Flag("enabled").Changed {
+ request.Enabled = &enabled
+ return client.SetSettings(ownerID, context, request)
+ }
+ return client.GetSettings(ownerID, context)
+ }()
+ if err != nil {
+ return fmt.Errorf("failed to run settings : %w", err)
+ }
+
+ if err = prettyJSONEncoder(cmd.OutOrStdout()).Encode(response); err != nil {
+ return fmt.Errorf("failed to encode settings: %w", err)
+ }
+
+ return nil
+ },
+ Args: cobra.ExactArgs(0),
+ Example: `circleci policy settings --owner-id 462d67f8-b232-4da4-a7de-0c86dd667d3f --enabled=true`,
+ }
+
+ cmd.Flags().StringVar(&ownerID, "owner-id", "", "the id of the policy's owner")
+ cmd.Flags().StringVar(&context, "context", "config", "policy context for decision")
+ cmd.Flags().BoolVar(&enabled, "enabled", false, "enable/disable policy decision evaluation in build pipeline")
+ if err := cmd.MarkFlagRequired("owner-id"); err != nil {
+ panic(err)
+ }
+
+ return cmd
+ }()
+
+ test := func() *cobra.Command {
+ var (
+ run string
+ verbose bool
+ debug bool
+ useJSON bool
+ format string
+ )
+
+ cmd := &cobra.Command{
+ Use: "test [path]",
+ Short: "runs policy tests",
+ RunE: func(cmd *cobra.Command, args []string) (err error) {
+ var include *regexp.Regexp
+ if run != "" {
+ include, err = regexp.Compile(run)
+ if err != nil {
+ return fmt.Errorf("--run value is not a valid regular expression: %w", err)
+ }
+ }
+
+ runnerOpts := tester.RunnerOptions{
+ Path: args[0],
+ Include: include,
+ }
+
+ runner, err := tester.NewRunner(runnerOpts)
+ if err != nil {
+ return fmt.Errorf("cannot instantiate runner: %w", err)
+ }
+
+ handlerOpts := tester.ResultHandlerOptions{
+ Verbose: verbose,
+ Debug: debug,
+ Dst: cmd.OutOrStdout(),
+ }
+
+ handler := func() tester.ResultHandler {
+ switch strings.ToLower(format) {
+ case "json":
+ return tester.MakeJSONResultHandler(handlerOpts)
+ case "junit":
+ return tester.MakeJUnitResultHandler(handlerOpts)
+ default:
+ if useJSON {
+ return tester.MakeJSONResultHandler(handlerOpts)
+ }
+ return tester.MakeDefaultResultHandler(handlerOpts)
+ }
+ }()
+
+ if !runner.RunAndHandleResults(handler) {
+ return errors.New("unsuccessful run")
+ }
+
+ return nil
+ },
+ Args: cobra.ExactArgs(1),
+ Example: "circleci policy test ./policies/...",
+ }
+
+ cmd.Flags().StringVar(&run, "run", "", "select which tests to run based on regular expression")
+ cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "print all tests instead of only failed tests")
+ cmd.Flags().BoolVar(&debug, "debug", false, "print test debug context. Sets verbose to true")
+ cmd.Flags().BoolVar(&useJSON, "json", false, "sprints json test results instead of standard output format")
+ _ = cmd.Flags().MarkDeprecated("json", "use --format=json to print json test results")
+ cmd.Flags().StringVar(&format, "format", "", "select desired format between json or junit")
+ return cmd
+ }()
+
+ cmd.AddCommand(push)
+ cmd.AddCommand(diff)
+ cmd.AddCommand(fetch)
+ cmd.AddCommand(logs)
+ cmd.AddCommand(decide)
+ cmd.AddCommand(eval)
+ cmd.AddCommand(settings)
+ cmd.AddCommand(test)
+
+ return cmd
+}
+
+func readMetadata(meta string, metaFile string) (map[string]interface{}, error) {
+ var metadata map[string]interface{}
+ if meta != "" && metaFile != "" {
+ return nil, fmt.Errorf("use either --meta or --metafile flag, but not both")
+ }
+ if meta != "" {
+ if err := json.Unmarshal([]byte(meta), &metadata); err != nil {
+ return nil, fmt.Errorf("failed to decode meta content: %w", err)
+ }
+ }
+ if metaFile != "" {
+ raw, err := os.ReadFile(metaFile)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read meta file: %w", err)
+ }
+ if err := yaml.Unmarshal(raw, &metadata); err != nil {
+ return nil, fmt.Errorf("failed to decode metafile content: %w", err)
+ }
+ }
+ return metadata, nil
+}
+
+// prettyJSONEncoder takes a writer and returns a new json encoder with indent set to two space characters
+func prettyJSONEncoder(dst io.Writer) *json.Encoder {
+ enc := json.NewEncoder(dst)
+ enc.SetIndent("", " ")
+ return enc
+}
+
+// getPolicyDecisionLocally takes path of policy path/directory and input (eg build config) as string, and performs policy evaluation locally
+func getPolicyDecisionLocally(policyPath string, rawInput []byte, meta map[string]interface{}) (*cpa.Decision, error) {
+ var input interface{}
+ if err := yaml.Unmarshal(rawInput, &input); err != nil {
+ return nil, fmt.Errorf("invalid input: %w", err)
+ }
+
+ p, err := cpa.LoadPolicyFromFS(policyPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load policy files: %w", err)
+ }
+
+ decision, err := p.Decide(context.Background(), input, cpa.Meta(meta))
+ if err != nil {
+ return nil, fmt.Errorf("failed to make decision: %w", err)
+ }
+
+ return decision, nil
+}
+
+// getPolicyEvaluationLocally takes path of policy path/directory and input (eg build config) as string, and performs policy evaluation locally and returns raw opa evaluation response
+func getPolicyEvaluationLocally(policyPath string, rawInput []byte, meta map[string]interface{}, query string) (interface{}, error) {
+ var input interface{}
+ if err := yaml.Unmarshal(rawInput, &input); err != nil {
+ return nil, fmt.Errorf("invalid input: %w", err)
+ }
+
+ p, err := cpa.LoadPolicyFromFS(policyPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load policy files: %w", err)
+ }
+
+ decision, err := p.Eval(context.Background(), query, input, cpa.Meta(meta))
+ if err != nil {
+ return nil, fmt.Errorf("failed to make decision: %w", err)
+ }
+
+ return decision, nil
+}
+
+func loadBundleFromFS(root string) (map[string]string, error) {
+ root = filepath.Clean(root)
+
+ rootInfo, err := os.Stat(root)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get path info: %w", err)
+ }
+ if !rootInfo.IsDir() {
+ return nil, fmt.Errorf("policy path is not a directory")
+ }
+
+ bundle := make(map[string]string)
+
+ err = filepath.WalkDir(root, func(path string, f fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if f.IsDir() || filepath.Ext(path) != ".rego" {
+ return nil
+ }
+
+ fileContent, err := os.ReadFile(filepath.Clean(path))
+ if err != nil {
+ return fmt.Errorf("failed to read file: %w", err)
+ }
+
+ bundle[path] = string(fileContent)
+
+ return nil
+ })
+
+ return bundle, err
+}
+
+func getAllDecisionLogs(client *policy.Client, ownerID string, context string, request policy.DecisionQueryRequest, spinnerOutputDst io.Writer) (interface{}, error) {
+ allLogs := make([]interface{}, 0)
+
+ spr := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(spinnerOutputDst))
+ spr.Suffix = " Fetching Policy Decision Logs..."
+
+ spr.PostUpdate = func(s *spinner.Spinner) {
+ s.Suffix = fmt.Sprintf(" Fetching Policy Decision Logs... downloaded %d logs...", len(allLogs))
+ }
+
+ spr.Start()
+ defer spr.Stop()
+
+ for {
+ logsBatch, err := client.GetDecisionLogs(ownerID, context, request)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(logsBatch) == 0 {
+ break
+ }
+
+ allLogs = append(allLogs, logsBatch...)
+ request.Offset = len(allLogs)
+ }
+ return allLogs, nil
+}
+
+func Confirm(w io.Writer, r io.Reader, question string) (bool, error) {
+ fmt.Fprint(w, question+" ")
+ var answer string
+
+ n, err := fmt.Fscanln(r, &answer)
+ if err != nil || n == 0 {
+ return false, fmt.Errorf("error in input")
+ }
+ answer = strings.ToLower(answer)
+ return answer == "y" || answer == "yes", nil
+}
diff --git a/cmd/policy/policy_test.go b/cmd/policy/policy_test.go
new file mode 100644
index 000000000..0c833c241
--- /dev/null
+++ b/cmd/policy/policy_test.go
@@ -0,0 +1,1290 @@
+package policy
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/spf13/cobra"
+ "gotest.tools/v3/assert"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+)
+
+//go:embed testdata
+var testdata embed.FS
+
+func testdataContent(t *testing.T, filePath string) string {
+ data, err := testdata.ReadFile(path.Join(".", "testdata", filePath))
+ assert.NilError(t, err)
+ return string(data)
+}
+
+func TestPushPolicyWithPrompt(t *testing.T) {
+ var requestCount int
+
+ expectedURLs := []string{
+ "/api/v1/owner/test-org/context/config/policy-bundle?dry=true",
+ "/api/v1/owner/test-org/context/config/policy-bundle",
+ }
+
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]interface{}
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.String(), expectedURLs[requestCount])
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.DeepEqual(t, body, map[string]interface{}{
+ "policies": map[string]interface{}{
+ filepath.Join("testdata", "test0", "policy.rego"): testdataContent(t, "test0/policy.rego"),
+ filepath.Join("testdata", "test0", "subdir", "meta-policy-subdir", "meta-policy.rego"): testdataContent(t, "test0/subdir/meta-policy-subdir/meta-policy.rego"),
+ },
+ })
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte("{}"))
+ requestCount++
+ }))
+ defer svr.Close()
+
+ config := &settings.Config{Token: "testtoken", HTTPClient: http.DefaultClient}
+ cmd := NewCommand(config, nil)
+
+ buffer := makeSafeBuffer()
+
+ pr, pw := io.Pipe()
+
+ cmd.SetOut(buffer)
+ cmd.SetErr(buffer)
+ cmd.SetIn(pr)
+
+ cmd.SetArgs([]string{
+ "push", "./testdata/test0",
+ "--owner-id", "test-org",
+ "--policy-base-url", svr.URL,
+ })
+
+ done := make(chan struct{})
+ go func() {
+ assert.NilError(t, cmd.Execute())
+ close(done)
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+
+ expectedMessage := "The following changes are going to be made: {}\n\nDo you wish to continue? (y/N) "
+ assert.Equal(t, buffer.String(), expectedMessage)
+
+ _, err := pw.Write([]byte("y\n"))
+ assert.NilError(t, err)
+
+ time.Sleep(50 * time.Millisecond)
+
+ assert.Equal(t, buffer.String()[len(expectedMessage):], "\nPolicy Bundle Pushed Successfully\n\ndiff: {}\n")
+
+ <-done
+}
+
+func TestPushPolicyBundleNoPrompt(t *testing.T) {
+ testcases := []struct {
+ Name string
+ Args []string
+ ServerHandler http.HandlerFunc
+ ExpectedErr string
+ ExpectedStdErr string
+ ExpectedStdOut string
+ }{
+ {
+ Name: "requires policy bundle directory path ",
+ Args: []string{"push", "--owner-id", "ownerID"},
+ ExpectedErr: "accepts 1 arg(s), received 0",
+ },
+ {
+ Name: "requires owner-id",
+ Args: []string{"push", "./testdata/test0/policy.rego"},
+ ExpectedErr: "required flag(s) \"owner-id\" not set",
+ },
+ {
+ Name: "fails for policy bundle directory path not found",
+ Args: []string{"push", "./testdata/directory_not_present", "--owner-id", "test-org"},
+ ExpectedErr: "failed to walk policy directory path: ",
+ },
+ {
+ Name: "fails if policy path points to a file instead of directory",
+ Args: []string{"push", "./testdata/test0/policy.rego", "--owner-id", "test-org"},
+ ExpectedErr: "failed to walk policy directory path: policy path is not a directory",
+ },
+ {
+ Name: "no policy files in given policy directory path",
+ Args: []string{"push", "./testdata/test0/no-valid-policy-files", "--owner-id", "test-org", "--context", "custom"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]interface{}
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/test-org/context/custom/policy-bundle")
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.DeepEqual(t, body, map[string]interface{}{
+ "policies": map[string]interface{}{},
+ })
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte("{}"))
+ },
+ ExpectedStdOut: "{}\n",
+ ExpectedStdErr: "Policy Bundle Pushed Successfully\n\ndiff: ",
+ },
+ {
+ Name: "sends appropriate desired request",
+ Args: []string{"push", "./testdata/test0", "--owner-id", "test-org", "--context", "custom"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]interface{}
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/test-org/context/custom/policy-bundle")
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.DeepEqual(t, body, map[string]interface{}{
+ "policies": map[string]interface{}{
+ filepath.Join("testdata", "test0", "policy.rego"): testdataContent(t, "test0/policy.rego"),
+ filepath.Join("testdata", "test0", "subdir", "meta-policy-subdir", "meta-policy.rego"): testdataContent(t, "test0/subdir/meta-policy-subdir/meta-policy.rego"),
+ },
+ })
+
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte("{}"))
+ },
+ ExpectedStdOut: "{}\n",
+ ExpectedStdErr: "Policy Bundle Pushed Successfully\n\ndiff: ",
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ if tc.ServerHandler == nil {
+ tc.ServerHandler = func(w http.ResponseWriter, r *http.Request) {}
+ }
+
+ svr := httptest.NewServer(tc.ServerHandler)
+ defer svr.Close()
+
+ cmd, stdout, stderr := makeCMD()
+
+ cmd.SetArgs(append(tc.Args, "--policy-base-url", svr.URL, "--no-prompt"))
+
+ err := cmd.Execute()
+ if tc.ExpectedErr != "" {
+ assert.ErrorContains(t, err, tc.ExpectedErr)
+ return
+ }
+
+ assert.NilError(t, err)
+ assert.Equal(t, stdout.String(), tc.ExpectedStdOut)
+ assert.Equal(t, stderr.String(), tc.ExpectedStdErr)
+ })
+ }
+}
+
+func TestDiffPolicyBundle(t *testing.T) {
+ testcases := []struct {
+ Name string
+ Args []string
+ ServerHandler http.HandlerFunc
+ ExpectedErr string
+ ExpectedStdErr string
+ ExpectedStdOut string
+ }{
+ {
+ Name: "requires policy bundle directory path ",
+ Args: []string{"diff", "--owner-id", "ownerID"},
+ ExpectedErr: "accepts 1 arg(s), received 0",
+ },
+ {
+ Name: "requires owner-id",
+ Args: []string{"diff", "./testdata/test0/policy.rego"},
+ ExpectedErr: "required flag(s) \"owner-id\" not set",
+ },
+ {
+ Name: "fails for policy bundle directory path not found",
+ Args: []string{"diff", "./testdata/directory_not_present", "--owner-id", "test-org"},
+ ExpectedErr: "failed to walk policy directory path: ",
+ },
+ {
+ Name: "fails if policy path points to a file instead of directory",
+ Args: []string{"diff", "./testdata/test0/policy.rego", "--owner-id", "test-org"},
+ ExpectedErr: "failed to walk policy directory path: policy path is not a directory",
+ },
+ {
+ Name: "no policy files in given policy directory path",
+ Args: []string{"diff", "./testdata/test0/no-valid-policy-files", "--owner-id", "test-org", "--context", "custom"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]interface{}
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/test-org/context/custom/policy-bundle?dry=true")
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.DeepEqual(t, body, map[string]interface{}{
+ "policies": map[string]interface{}{},
+ })
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte("{}"))
+ },
+ ExpectedStdOut: "{}\n",
+ },
+ {
+ Name: "sends appropriate desired request",
+ Args: []string{"diff", "./testdata/test0", "--owner-id", "test-org", "--context", "custom"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]interface{}
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/test-org/context/custom/policy-bundle?dry=true")
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.DeepEqual(t, body, map[string]interface{}{
+ "policies": map[string]interface{}{
+ filepath.Join("testdata", "test0", "policy.rego"): testdataContent(t, "test0/policy.rego"),
+ filepath.Join("testdata", "test0", "subdir", "meta-policy-subdir", "meta-policy.rego"): testdataContent(t, "test0/subdir/meta-policy-subdir/meta-policy.rego"),
+ },
+ })
+
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte("{}"))
+ },
+ ExpectedStdOut: "{}\n",
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ if tc.ServerHandler == nil {
+ tc.ServerHandler = func(w http.ResponseWriter, r *http.Request) {}
+ }
+
+ svr := httptest.NewServer(tc.ServerHandler)
+ defer svr.Close()
+
+ cmd, stdout, stderr := makeCMD()
+
+ cmd.SetArgs(append(tc.Args, "--policy-base-url", svr.URL))
+
+ err := cmd.Execute()
+ if tc.ExpectedErr != "" {
+ assert.ErrorContains(t, err, tc.ExpectedErr)
+ return
+ }
+
+ assert.NilError(t, err)
+ assert.Equal(t, stdout.String(), tc.ExpectedStdOut)
+ assert.Equal(t, stderr.String(), tc.ExpectedStdErr)
+ })
+ }
+}
+
+func TestFetchPolicyBundle(t *testing.T) {
+ testcases := []struct {
+ Name string
+ Args []string
+ ServerHandler http.HandlerFunc
+ ExpectedOutput string
+ ExpectedErr string
+ }{
+ {
+ Name: "requires owner-id",
+ Args: []string{"fetch", "policyID"},
+ ExpectedErr: "required flag(s) \"owner-id\" not set",
+ },
+ {
+ Name: "gets error response",
+ Args: []string{"fetch", "policyName", "--owner-id", "ownerID", "--context", "someContext"},
+ ExpectedErr: "failed to fetch policy bundle: unexpected status-code: 403 - Forbidden",
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerID/context/someContext/policy-bundle/policyName")
+ w.WriteHeader(http.StatusForbidden)
+ _, err := w.Write([]byte(`{"error": "Forbidden"}`))
+ assert.NilError(t, err)
+ },
+ },
+ {
+ Name: "successfully fetches single policy",
+ Args: []string{"fetch", "my_policy", "--owner-id", "462d67f8-b232-4da4-a7de-0c86dd667d3f"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/462d67f8-b232-4da4-a7de-0c86dd667d3f/context/config/policy-bundle/my_policy")
+ _, err := w.Write([]byte(`{
+ "content": "package org\n\npolicy_name[\"my_policy\"] { true }",
+ "created_at": "2022-08-10T10:47:01.859756-04:00",
+ "created_by": "737fc204-4048-49fd-9aee-96c97698ed28",
+ "name": "my_policy"
+ }`))
+ assert.NilError(t, err)
+ },
+ ExpectedOutput: `{
+ "content": "package org\n\npolicy_name[\"my_policy\"] { true }",
+ "created_at": "2022-08-10T10:47:01.859756-04:00",
+ "created_by": "737fc204-4048-49fd-9aee-96c97698ed28",
+ "name": "my_policy"
+}
+`,
+ },
+ {
+ Name: "successfully fetches policy bundle",
+ Args: []string{"fetch", "--owner-id", "462d67f8-b232-4da4-a7de-0c86dd667d3f"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/462d67f8-b232-4da4-a7de-0c86dd667d3f/context/config/policy-bundle/")
+ _, err := w.Write([]byte(`{
+ "a": {
+ "content": "package org\n\npolicy_name[\"a\"] { true }",
+ "created_at": "2022-08-10T10:47:01.859756-04:00",
+ "created_by": "737fc204-4048-49fd-9aee-96c97698ed28",
+ "name": "a"
+ },
+ "b": {
+ "content": "package org\n\npolicy_name[\"b\"] { true }",
+ "created_at": "2022-08-10T10:47:01.859756-04:00",
+ "created_by": "737fc204-4048-49fd-9aee-96c97698ed28",
+ "name": "b"
+ }
+}`))
+ assert.NilError(t, err)
+ },
+ ExpectedOutput: `{
+ "a": {
+ "content": "package org\n\npolicy_name[\"a\"] { true }",
+ "created_at": "2022-08-10T10:47:01.859756-04:00",
+ "created_by": "737fc204-4048-49fd-9aee-96c97698ed28",
+ "name": "a"
+ },
+ "b": {
+ "content": "package org\n\npolicy_name[\"b\"] { true }",
+ "created_at": "2022-08-10T10:47:01.859756-04:00",
+ "created_by": "737fc204-4048-49fd-9aee-96c97698ed28",
+ "name": "b"
+ }
+}
+`,
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ if tc.ServerHandler == nil {
+ tc.ServerHandler = func(w http.ResponseWriter, r *http.Request) {}
+ }
+
+ svr := httptest.NewServer(tc.ServerHandler)
+ defer svr.Close()
+
+ cmd, stdout, _ := makeCMD()
+
+ cmd.SetArgs(append(tc.Args, "--policy-base-url", svr.URL))
+
+ err := cmd.Execute()
+ if tc.ExpectedErr == "" {
+ assert.NilError(t, err)
+ } else {
+ assert.Error(t, err, tc.ExpectedErr)
+ return
+ }
+
+ assert.Equal(t, stdout.String(), tc.ExpectedOutput)
+ })
+ }
+}
+
+func TestGetDecisionLogs(t *testing.T) {
+ testcases := []struct {
+ Name string
+ Args []string
+ ServerHandler http.HandlerFunc
+ ExpectedOutput string
+ ExpectedErr string
+ }{
+ {
+ Name: "requires owner-id",
+ Args: []string{"logs"},
+ ExpectedErr: "required flag(s) \"owner-id\" not set",
+ },
+ {
+ Name: "invalid --after filter value",
+ Args: []string{"logs", "--owner-id", "ownerID", "--after", "1/2/2022"},
+ ExpectedErr: `error in parsing --after value: This date has ambiguous mm/dd vs dd/mm type format`,
+ },
+ {
+ Name: "invalid --before filter value",
+ Args: []string{"logs", "--owner-id", "ownerID", "--before", "1/2/2022"},
+ ExpectedErr: `error in parsing --before value: This date has ambiguous mm/dd vs dd/mm type format`,
+ },
+ {
+ Name: "gives error when a filter is provided when decisionID is also provided",
+ Args: []string{"logs", "decisionID", "--owner-id", "ownerID", "--branch", "main"},
+ ExpectedErr: `filters are not accepted when decision_id is provided`,
+ },
+ {
+ Name: "gives error when --policy-bundle flag is used but decisionID is not provided",
+ Args: []string{"logs", "--owner-id", "ownerID", "--policy-bundle"},
+ ExpectedErr: `decision_id is required when --policy-bundle flag is used`,
+ },
+ {
+ Name: "no filter is set",
+ Args: []string{"logs", "--owner-id", "ownerID"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerID/context/config/decision")
+ _, err := w.Write([]byte("[]"))
+ assert.NilError(t, err)
+ },
+ ExpectedOutput: "[]\n",
+ },
+ {
+ Name: "all filters are set",
+ Args: []string{
+ "logs", "--owner-id", "ownerID", "--status", "PASS", "--after", "2022/03/14", "--before", "2022/03/15",
+ "--branch", "branchValue", "--project-id", "projectIDValue",
+ },
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerID/context/config/decision?after=2022-03-14T00%3A00%3A00Z&before=2022-03-15T00%3A00%3A00Z&branch=branchValue&project_id=projectIDValue&status=PASS")
+ _, err := w.Write([]byte("[]"))
+ assert.NilError(t, err)
+ },
+ ExpectedOutput: "[]\n",
+ },
+ {
+ Name: "gets error response",
+ Args: []string{"logs", "--owner-id", "ownerID"},
+ ExpectedErr: "failed to get policy decision logs: unexpected status-code: 403 - Forbidden",
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerID/context/config/decision")
+ w.WriteHeader(http.StatusForbidden)
+ _, err := w.Write([]byte(`{"error": "Forbidden"}`))
+ assert.NilError(t, err)
+ },
+ },
+ {
+ Name: "successfully gets decision logs",
+ Args: []string{"logs", "--owner-id", "ownerID"},
+ ServerHandler: func() http.HandlerFunc {
+ var count int
+ return func(w http.ResponseWriter, r *http.Request) {
+ defer func() { count++ }()
+
+ assert.Equal(t, r.Method, "GET")
+
+ if count == 0 {
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerID/context/config/decision")
+ _, err := w.Write([]byte(`
+ [
+ {
+ "created_at": "2022-08-11T09:20:40.674594-04:00",
+ "decision": {
+ "enabled_rules": [
+ "branch_is_main"
+ ],
+ "status": "PASS"
+ },
+ "metadata": {},
+ "policies": [
+ "8c69adc542bcfd6e65f5d5a2b6a4e3764480db2253cd075d0954e64a1f827a9c695c916d5a49302991df781447b3951410824dce8a8282d11ed56302272cf6fb",
+ "3124131001ec20b4b524260ababa6411190a1bc9c5ac3219ccc2d21109fc5faf4bb9f7bbe38f3f798d9c232d68564390e0ca560877711f3f2ff7f89e10eef685"
+ ],
+ "time_taken_ms": 4
+ }
+ ]`),
+ )
+ assert.NilError(t, err)
+ } else if count == 1 {
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerID/context/config/decision?offset=1")
+ _, err := w.Write([]byte("[]"))
+ assert.NilError(t, err)
+ } else {
+ t.Fatal("did not expect more than two requests but received a third")
+ }
+ }
+ }(),
+ ExpectedOutput: `[
+ {
+ "created_at": "2022-08-11T09:20:40.674594-04:00",
+ "decision": {
+ "enabled_rules": [
+ "branch_is_main"
+ ],
+ "status": "PASS"
+ },
+ "metadata": {},
+ "policies": [
+ "8c69adc542bcfd6e65f5d5a2b6a4e3764480db2253cd075d0954e64a1f827a9c695c916d5a49302991df781447b3951410824dce8a8282d11ed56302272cf6fb",
+ "3124131001ec20b4b524260ababa6411190a1bc9c5ac3219ccc2d21109fc5faf4bb9f7bbe38f3f798d9c232d68564390e0ca560877711f3f2ff7f89e10eef685"
+ ],
+ "time_taken_ms": 4
+ }
+]
+`,
+ },
+ {
+ Name: "successfully gets a decision log for given decision ID",
+ Args: []string{"logs", "--owner-id", "ownerID", "decisionID"},
+ ServerHandler: func() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerID/context/config/decision/decisionID")
+ _, err := w.Write([]byte("{}"))
+ assert.NilError(t, err)
+ }
+ }(),
+ ExpectedOutput: "{}\n",
+ },
+ {
+ Name: "successfully gets policy-bundle for given decision ID",
+ Args: []string{"logs", "--owner-id", "ownerID", "decisionID", "--policy-bundle"},
+ ServerHandler: func() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerID/context/config/decision/decisionID/policy-bundle")
+ _, err := w.Write([]byte("{}"))
+ assert.NilError(t, err)
+ }
+ }(),
+ ExpectedOutput: "{}\n",
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ if tc.ServerHandler == nil {
+ tc.ServerHandler = func(w http.ResponseWriter, r *http.Request) {}
+ }
+
+ svr := httptest.NewServer(tc.ServerHandler)
+ defer svr.Close()
+
+ cmd, stdout, _ := makeCMD()
+
+ cmd.SetArgs(append(tc.Args, "--policy-base-url", svr.URL))
+
+ err := cmd.Execute()
+ if tc.ExpectedErr == "" {
+ assert.NilError(t, err)
+ } else {
+ assert.Error(t, err, tc.ExpectedErr)
+ return
+ }
+ assert.Equal(t, stdout.String(), tc.ExpectedOutput)
+ })
+ }
+}
+
+func TestMakeDecisionCommand(t *testing.T) {
+ testcases := []struct {
+ Name string
+ Args []string
+ ServerHandler http.HandlerFunc
+ ExpectedOutput string
+ ExpectedErr string
+ }{
+ {
+ Name: "requires flags",
+ Args: []string{"decide"},
+ ExpectedErr: `required flag(s) "input" not set`,
+ },
+ {
+ Name: "sends expected request",
+ Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test1/test.yml"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision")
+
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "input": "test: config\n",
+ })
+
+ _, _ = io.WriteString(w, `{"status":"PASS"}`)
+ },
+ ExpectedOutput: "{\n \"status\": \"PASS\"\n}\n",
+ },
+ {
+ Name: "passes when decision status = HARD_FAIL AND --strict is OFF",
+ Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test1/test.yml"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision")
+
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "input": "test: config\n",
+ })
+
+ _, _ = io.WriteString(w, `{"status":"HARD_FAIL"}`)
+ },
+ ExpectedOutput: "{\n \"status\": \"HARD_FAIL\"\n}\n",
+ },
+ {
+ Name: "fails when decision status = HARD_FAIL AND --strict is ON",
+ Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test1/test.yml", "--strict"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision")
+
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "input": "test: config\n",
+ })
+
+ _, _ = io.WriteString(w, `{"status":"HARD_FAIL"}`)
+ },
+ ExpectedErr: "policy decision status: HARD_FAIL",
+ },
+ {
+ Name: "passes when decision status = ERROR AND --strict is OFF",
+ Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test1/test.yml"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision")
+
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "input": "test: config\n",
+ })
+
+ _, _ = io.WriteString(w, `{"status":"ERROR", "reason": "some reason"}`)
+ },
+ ExpectedOutput: "{\n \"status\": \"ERROR\",\n \"reason\": \"some reason\"\n}\n",
+ },
+ {
+ Name: "fails when decision status = ERROR AND --strict is ON",
+ Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test1/test.yml", "--strict"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/config/decision")
+
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "input": "test: config\n",
+ })
+
+ _, _ = io.WriteString(w, `{"status":"ERROR", "reason": "some reason"}`)
+ },
+ ExpectedErr: "policy decision status: ERROR",
+ },
+ {
+ Name: "sends expected request with context",
+ Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test1/test.yml", "--context", "custom"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/custom/decision")
+
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "input": "test: config\n",
+ })
+
+ _, _ = io.WriteString(w, `{"status":"PASS"}`)
+ },
+ ExpectedOutput: "{\n \"status\": \"PASS\"\n}\n",
+ },
+ {
+ Name: "sends expected request with meta",
+ Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test1/test.yml", "--context", "custom", "--meta", `{"project_id": "test-project-id","vcs": {"branch": "main"}}`},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/custom/decision")
+
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "input": "test: config\n",
+ "metadata": map[string]interface{}{
+ "project_id": "test-project-id",
+ "vcs": map[string]any{"branch": "main"},
+ },
+ })
+
+ _, _ = io.WriteString(w, `{"status":"PASS"}`)
+ },
+ ExpectedOutput: "{\n \"status\": \"PASS\"\n}\n",
+ },
+ {
+ Name: "sends expected request with metafile",
+ Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test1/test.yml", "--context", "custom", "--metafile", "./testdata/test1/meta.yml"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "POST")
+ assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/context/custom/decision")
+
+ var payload map[string]interface{}
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))
+
+ assert.DeepEqual(t, payload, map[string]interface{}{
+ "input": "test: config\n",
+ "metadata": map[string]interface{}{
+ "project_id": "test-project-id",
+ "vcs": map[string]any{"branch": "main"},
+ },
+ })
+
+ _, _ = io.WriteString(w, `{"status":"PASS"}`)
+ },
+ ExpectedOutput: "{\n \"status\": \"PASS\"\n}\n",
+ },
+ {
+ Name: "fails on unexpected status code",
+ Args: []string{"decide", "--input", "./testdata/test1/test.yml", "--owner-id", "test-owner"},
+ ServerHandler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(500)
+ _, _ = io.WriteString(w, `{"error":"oopsie!"}`)
+ },
+
+ ExpectedErr: "failed to make decision: unexpected status-code: 500 - oopsie!",
+ },
+ {
+ Name: "fails if neither local-policy nor owner-id is provided",
+ Args: []string{"decide", "--input", "./testdata/test1/test.yml"},
+ ExpectedErr: "either [policy_file_or_dir_path] or --owner-id is required",
+ },
+ {
+ Name: "fails if both local-policy and owner-id are provided",
+ Args: []string{"decide", "./testdata/test0/policy.rego", "--input", "./testdata/test1/test.yml", "--owner-id", "test-owner"},
+ ExpectedErr: "either [policy_file_or_dir_path] or --owner-id is required",
+ },
+ {
+ Name: "fails for input file not found",
+ Args: []string{"decide", "./testdata/test0/policy.rego", "--input", "./testdata/no_such_file.yml"},
+ ExpectedErr: "failed to read input file: open ./testdata/no_such_file.yml: ",
+ },
+ {
+ Name: "fails for policy FILE/DIRECTORY not found",
+ Args: []string{"decide", "./testdata/no_such_file.rego", "--input", "./testdata/test1/test.yml"},
+ ExpectedErr: "failed to make decision: failed to load policy files: failed to walk root: ",
+ },
+ {
+ Name: "fails if both meta and metafile are provided",
+ Args: []string{"decide", "./testdata/test0/policy.rego", "--input", "./testdata/test1/test.yml", "--meta", "{}", "--metafile", "somefile"},
+ ExpectedErr: "failed to read metadata: use either --meta or --metafile flag, but not both",
+ },
+ {
+ Name: "successfully performs decision for policy FILE provided locally",
+ Args: []string{"decide", "./testdata/test0/policy.rego", "--input", "./testdata/test0/config.yml"},
+ ExpectedOutput: `{
+ "status": "PASS",
+ "enabled_rules": [
+ "branch_is_main"
+ ]
+}
+`,
+ },
+ {
+ Name: "successfully performs decision for policy FILE provided locally, passes when decision = HARD_FAIL and strict = OFF",
+ Args: []string{"decide", "./testdata/test2/hard_fail_policy.rego", "--input", "./testdata/test0/config.yml"},
+ ExpectedOutput: `{
+ "status": "HARD_FAIL",
+ "enabled_rules": [
+ "always_hard_fails"
+ ],
+ "hard_failures": [
+ {
+ "rule": "always_hard_fails",
+ "reason": "0 is not equals 1"
+ }
+ ]
+}
+`,
+ },
+ {
+ Name: "successfully performs decision for policy FILE provided locally, fails when decision = HARD_FAIL and strict = ON",
+ Args: []string{"decide", "./testdata/test2/hard_fail_policy.rego", "--input", "./testdata/test0/config.yml", "--strict"},
+ ExpectedErr: "policy decision status: HARD_FAIL",
+ },
+ {
+ Name: "successfully performs decision for policy FILE provided locally, passes when decision = ERROR and strict = OFF",
+ Args: []string{"decide", "./testdata/test3/runtime_error_policy.rego", "--input", "./testdata/test0/config.yml"},
+ ExpectedOutput: `{
+ "status": "ERROR",
+ "reason": "./testdata/test3/runtime_error_policy.rego:8: eval_conflict_error: complete rules must not produce multiple outputs"
+}
+`,
+ },
+ {
+ Name: "successfully performs decision for policy FILE provided locally, fails when decision = ERROR and strict = ON",
+ Args: []string{"decide", "./testdata/test3/runtime_error_policy.rego", "--input", "./testdata/test0/config.yml", "--strict"},
+ ExpectedErr: "policy decision status: ERROR",
+ },
+ {
+ Name: "successfully performs decision with meta for policy FILE provided locally",
+ Args: []string{
+ "decide", "./testdata/test0/subdir/meta-policy-subdir/meta-policy.rego", "--meta",
+ `{"project_id": "test-project-id","vcs": {"branch": "main"}}`, "--input", "./testdata/test0/config.yml",
+ },
+ ExpectedOutput: `{
+ "status": "PASS",
+ "enabled_rules": [
+ "enabled"
+ ]
+}
+`,
+ },
+ {
+ Name: "successfully performs decision with metafile for policy FILE provided locally",
+ Args: []string{
+ "decide", "./testdata/test0/subdir/meta-policy-subdir/meta-policy.rego", "--metafile",
+ "./testdata/test1/meta.yml", "--input", "./testdata/test0/config.yml",
+ },
+ ExpectedOutput: `{
+ "status": "PASS",
+ "enabled_rules": [
+ "enabled"
+ ]
+}
+`,
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ if tc.ServerHandler == nil {
+ tc.ServerHandler = func(w http.ResponseWriter, r *http.Request) {}
+ }
+
+ svr := httptest.NewServer(tc.ServerHandler)
+ defer svr.Close()
+
+ cmd, stdout, _ := makeCMD()
+
+ cmd.SetArgs(append(tc.Args, "--policy-base-url", svr.URL))
+
+ err := cmd.Execute()
+ if tc.ExpectedErr == "" {
+ assert.NilError(t, err)
+ } else {
+ assert.ErrorContains(t, err, tc.ExpectedErr)
+ return
+ }
+ assert.Equal(t, stdout.String(), tc.ExpectedOutput)
+ })
+ }
+}
+
+func TestRawOPAEvaluationCommand(t *testing.T) {
+ testcases := []struct {
+ Name string
+ Args []string
+ ServerHandler http.HandlerFunc
+ ExpectedOutput string
+ ExpectedErr string
+ }{
+ {
+ Name: "fails if local-policy is not provided",
+ Args: []string{"eval", "--input", "./testdata/test1/test.yml"},
+ ExpectedErr: `accepts 1 arg(s), received 0`,
+ },
+ {
+ Name: "fails if input is not provided",
+ Args: []string{"eval", "./testdata/test0/policy.rego"},
+ ExpectedErr: `required flag(s) "input" not set`,
+ },
+ {
+ Name: "fails for input file not found",
+ Args: []string{"eval", "./testdata/test0/policy.rego", "--input", "./testdata/no_such_file.yml"},
+ ExpectedErr: "failed to read input file: open ./testdata/no_such_file.yml: ",
+ },
+ {
+ Name: "fails for policy FILE/DIRECTORY not found",
+ Args: []string{"eval", "./testdata/no_such_file.rego", "--input", "./testdata/test1/test.yml"},
+ ExpectedErr: "failed to make decision: failed to load policy files: failed to walk root: ",
+ },
+ {
+ Name: "fails if both meta and metafile are provided",
+ Args: []string{"eval", "./testdata/test0/policy.rego", "--input", "./testdata/test1/test.yml", "--meta", "{}", "--metafile", "somefile"},
+ ExpectedErr: "failed to read metadata: use either --meta or --metafile flag, but not both",
+ },
+ {
+ Name: "successfully performs raw opa evaluation for policy FILE provided locally, input and meta",
+ Args: []string{
+ "eval", "./testdata/test0/subdir/meta-policy-subdir/meta-policy.rego",
+ "--meta", `{"project_id": "test-project-id","vcs": {"branch": "main"}}`,
+ "--input", "./testdata/test0/config.yml",
+ },
+ ExpectedOutput: `{
+ "meta": {
+ "vcs": {
+ "branch": "main"
+ },
+ "project_id": "test-project-id"
+ },
+ "org": {
+ "enable_rule": [
+ "enabled"
+ ],
+ "policy_name": [
+ "meta_policy_test"
+ ]
+ }
+}
+`,
+ },
+ {
+ Name: "successfully performs raw opa evaluation for policy FILE provided locally, input and metafile",
+ Args: []string{
+ "eval", "./testdata/test0/subdir/meta-policy-subdir/meta-policy.rego",
+ "--metafile", "./testdata/test1/meta.yml",
+ "--input", "./testdata/test0/config.yml",
+ },
+ ExpectedOutput: `{
+ "meta": {
+ "vcs": {
+ "branch": "main"
+ },
+ "project_id": "test-project-id"
+ },
+ "org": {
+ "enable_rule": [
+ "enabled"
+ ],
+ "policy_name": [
+ "meta_policy_test"
+ ]
+ }
+}
+`,
+ },
+ {
+ Name: "successfully performs raw opa evaluation for policy FILE provided locally, input, meta and query",
+ Args: []string{
+ "eval", "./testdata/test0/subdir/meta-policy-subdir/meta-policy.rego",
+ "--meta", `{"project_id": "test-project-id","vcs": {"branch": "main"}}`,
+ "--input", "./testdata/test0/config.yml",
+ "--query", "data.org.enable_rule",
+ },
+ ExpectedOutput: `[
+ "enabled"
+]
+`,
+ },
+ {
+ Name: "successfully performs raw opa evaluation for policy FILE provided locally, input, metafile and query",
+ Args: []string{
+ "eval", "./testdata/test0/subdir/meta-policy-subdir/meta-policy.rego",
+ "--metafile", "./testdata/test1/meta.yml",
+ "--input", "./testdata/test0/config.yml",
+ "--query", "data.org.enable_rule",
+ },
+ ExpectedOutput: `[
+ "enabled"
+]
+`,
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ if tc.ServerHandler == nil {
+ tc.ServerHandler = func(w http.ResponseWriter, r *http.Request) {}
+ }
+
+ svr := httptest.NewServer(tc.ServerHandler)
+ defer svr.Close()
+
+ cmd, stdout, _ := makeCMD()
+
+ args := append(tc.Args, "--policy-base-url", svr.URL)
+
+ cmd.SetArgs(args)
+
+ err := cmd.Execute()
+ if tc.ExpectedErr == "" {
+ assert.NilError(t, err)
+ } else {
+ assert.ErrorContains(t, err, tc.ExpectedErr)
+ return
+ }
+
+ var actual, expected any
+ assert.NilError(t, json.Unmarshal(stdout.Bytes(), &actual))
+ assert.NilError(t, json.Unmarshal([]byte(tc.ExpectedOutput), &expected))
+
+ assert.DeepEqual(t, actual, expected)
+ })
+ }
+}
+
+func TestGetSetSettings(t *testing.T) {
+ testcases := []struct {
+ Name string
+ Args []string
+ ServerHandler http.HandlerFunc
+ ExpectedOutput string
+ ExpectedErr string
+ }{
+ {
+ Name: "requires owner-id",
+ Args: []string{"settings"},
+ ExpectedErr: "required flag(s) \"owner-id\" not set",
+ },
+ {
+ Name: "gets error response",
+ Args: []string{"settings", "--owner-id", "ownerID", "--context", "someContext"},
+ ExpectedErr: "failed to run settings : unexpected status-code: 403 - Forbidden",
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/ownerID/context/someContext/decision/settings")
+ w.WriteHeader(http.StatusForbidden)
+ _, err := w.Write([]byte(`{"error": "Forbidden"}`))
+ assert.NilError(t, err)
+ },
+ },
+ {
+ Name: "successfully fetches settings",
+ Args: []string{"settings", "--owner-id", "462d67f8-b232-4da4-a7de-0c86dd667d3f"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/462d67f8-b232-4da4-a7de-0c86dd667d3f/context/config/decision/settings")
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(`{"enabled": true}`))
+ assert.NilError(t, err)
+ },
+ ExpectedOutput: `{
+ "enabled": true
+}
+`,
+ },
+ {
+ Name: "successfully sets settings (--enabled)",
+ Args: []string{"settings", "--owner-id", "462d67f8-b232-4da4-a7de-0c86dd667d3f", "--enabled"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]interface{}
+ assert.Equal(t, r.Method, "PATCH")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/462d67f8-b232-4da4-a7de-0c86dd667d3f/context/config/decision/settings")
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.DeepEqual(t, body, map[string]interface{}{"enabled": true})
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(`{"enabled": true}`))
+ assert.NilError(t, err)
+ },
+ ExpectedOutput: `{
+ "enabled": true
+}
+`,
+ },
+ {
+ Name: "successfully sets settings (--enabled=true)",
+ Args: []string{"settings", "--owner-id", "462d67f8-b232-4da4-a7de-0c86dd667d3f", "--enabled=true"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]interface{}
+ assert.Equal(t, r.Method, "PATCH")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/462d67f8-b232-4da4-a7de-0c86dd667d3f/context/config/decision/settings")
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.DeepEqual(t, body, map[string]interface{}{"enabled": true})
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(`{"enabled": true}`))
+ assert.NilError(t, err)
+ },
+ ExpectedOutput: `{
+ "enabled": true
+}
+`,
+ },
+ {
+ Name: "successfully sets settings (--enabled=false)",
+ Args: []string{"settings", "--owner-id", "462d67f8-b232-4da4-a7de-0c86dd667d3f", "--enabled=false"},
+ ServerHandler: func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]interface{}
+ assert.Equal(t, r.Method, "PATCH")
+ assert.Equal(t, r.URL.String(), "/api/v1/owner/462d67f8-b232-4da4-a7de-0c86dd667d3f/context/config/decision/settings")
+ assert.NilError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.DeepEqual(t, body, map[string]interface{}{"enabled": false})
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(`{"enabled": false}`))
+ assert.NilError(t, err)
+ },
+ ExpectedOutput: `{
+ "enabled": false
+}
+`,
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.Name, func(t *testing.T) {
+ if tc.ServerHandler == nil {
+ tc.ServerHandler = func(w http.ResponseWriter, r *http.Request) {}
+ }
+
+ svr := httptest.NewServer(tc.ServerHandler)
+ defer svr.Close()
+
+ cmd, stdout, _ := makeCMD()
+
+ cmd.SetArgs(append(tc.Args, "--policy-base-url", svr.URL))
+
+ err := cmd.Execute()
+ if tc.ExpectedErr == "" {
+ assert.NilError(t, err)
+ } else {
+ assert.Error(t, err, tc.ExpectedErr)
+ return
+ }
+
+ assert.Equal(t, stdout.String(), tc.ExpectedOutput)
+ })
+ }
+}
+
+const jsonDeprecationMessage = "Flag --json has been deprecated, use --format=json to print json test results\n"
+
+func TestTestRunner(t *testing.T) {
+ cases := []struct {
+ Name string
+ Verbose bool
+ Debug bool
+ Run string
+ Json bool
+ Format string
+ Expected func(*testing.T, string)
+ }{
+ {
+ Name: "default options",
+ Expected: func(t *testing.T, s string) {
+ assert.Check(t, strings.Contains(s, "testdata/test_policies"))
+ assert.Check(t, strings.Contains(s, "2/2 tests passed"))
+ assert.Check(t, !strings.Contains(s, "test_feature"), "should not have verbose output")
+ },
+ },
+ {
+ Name: "verbose",
+ Verbose: true,
+ Expected: func(t *testing.T, s string) {
+ assert.Check(t, strings.Contains(s, "test_feature"))
+ assert.Check(t, strings.Contains(s, "test_main"))
+ assert.Check(t, strings.Contains(s, "2/2 tests passed"))
+ },
+ },
+ {
+ Name: "verbose with run",
+ Verbose: true,
+ Run: "test_main",
+ Expected: func(t *testing.T, s string) {
+ assert.Check(t, strings.Contains(s, "test_main"))
+ assert.Check(t, !strings.Contains(s, "test_feature"))
+ assert.Check(t, strings.Contains(s, "1/1 tests passed"))
+ },
+ },
+ {
+ Name: "debug",
+ Debug: true,
+ Expected: func(t *testing.T, s string) {
+ assert.Check(t, strings.Contains(s, "---- Debug Test Context ----"))
+ },
+ },
+ {
+ Name: "json",
+ Json: true,
+ Expected: func(t *testing.T, s string) {
+ assert.Check(t, strings.HasPrefix(s, jsonDeprecationMessage))
+ assert.Check(t, s[len(jsonDeprecationMessage)] == '[')
+ assert.Check(t, s[len(s)-2] == ']')
+ },
+ },
+ {
+ Name: "format:json",
+ Format: "json",
+ Expected: func(t *testing.T, s string) {
+ assert.Check(t, s[0] == '[')
+ assert.Check(t, s[len(s)-2] == ']')
+ },
+ },
+ {
+ Name: "format:junit",
+ Format: "junit",
+ Expected: func(t *testing.T, s string) {
+ assert.Check(t, strings.Contains(s, " [flags]
+
+Examples:
+circleci policy diff ./policies --owner-id 462d67f8-b232-4da4-a7de-0c86dd667d3f
+
+Flags:
+ --context string policy context (default "config")
+ --owner-id string the id of the policy's owner
+
+Global Flags:
+ --policy-base-url string base url for policy api (default "https://internal.circleci.com")
diff --git a/cmd/policy/testdata/policy/eval-expected-usage.txt b/cmd/policy/testdata/policy/eval-expected-usage.txt
new file mode 100644
index 000000000..f35daf210
--- /dev/null
+++ b/cmd/policy/testdata/policy/eval-expected-usage.txt
@@ -0,0 +1,14 @@
+Usage:
+ policy eval [flags]
+
+Examples:
+circleci policy eval ./policies --input ./.circleci/config.yml
+
+Flags:
+ --input string path to input file
+ --meta string decision metadata (json string)
+ --metafile string decision metadata file
+ --query string policy decision query (default "data")
+
+Global Flags:
+ --policy-base-url string base url for policy api (default "https://internal.circleci.com")
diff --git a/cmd/policy/testdata/policy/fetch-expected-usage.txt b/cmd/policy/testdata/policy/fetch-expected-usage.txt
new file mode 100644
index 000000000..28e2bfbdc
--- /dev/null
+++ b/cmd/policy/testdata/policy/fetch-expected-usage.txt
@@ -0,0 +1,12 @@
+Usage:
+ policy fetch [policy_name] [flags]
+
+Examples:
+circleci policy fetch --owner-id 516425b2-e369-421b-838d-920e1f51b0f5
+
+Flags:
+ --context string policy context (default "config")
+ --owner-id string the id of the policy's owner
+
+Global Flags:
+ --policy-base-url string base url for policy api (default "https://internal.circleci.com")
diff --git a/cmd/policy/testdata/policy/logs-expected-usage.txt b/cmd/policy/testdata/policy/logs-expected-usage.txt
new file mode 100644
index 000000000..7a0152630
--- /dev/null
+++ b/cmd/policy/testdata/policy/logs-expected-usage.txt
@@ -0,0 +1,19 @@
+Usage:
+ policy logs [decision_id] [flags]
+
+Examples:
+circleci policy logs --owner-id 462d67f8-b232-4da4-a7de-0c86dd667d3f --after 2022/03/14 --out output.json
+
+Flags:
+ --after string filter decision logs triggered AFTER this datetime
+ --before string filter decision logs triggered BEFORE this datetime
+ --branch string filter decision logs based on branch name
+ --context string policy context (default "config")
+ --out string specify output file name
+ --owner-id string the id of the policy's owner
+ --policy-bundle get only the policy bundle for given decisionID
+ --project-id string filter decision logs based on project-id
+ --status string filter decision logs based on their status
+
+Global Flags:
+ --policy-base-url string base url for policy api (default "https://internal.circleci.com")
diff --git a/cmd/policy/testdata/policy/push-expected-usage.txt b/cmd/policy/testdata/policy/push-expected-usage.txt
new file mode 100644
index 000000000..1ec9fca05
--- /dev/null
+++ b/cmd/policy/testdata/policy/push-expected-usage.txt
@@ -0,0 +1,13 @@
+Usage:
+ policy push [flags]
+
+Examples:
+circleci policy push ./policies --owner-id 462d67f8-b232-4da4-a7de-0c86dd667d3f
+
+Flags:
+ --context string policy context (default "config")
+ --no-prompt removes the prompt
+ --owner-id string the id of the policy's owner
+
+Global Flags:
+ --policy-base-url string base url for policy api (default "https://internal.circleci.com")
diff --git a/cmd/policy/testdata/policy/settings-expected-usage.txt b/cmd/policy/testdata/policy/settings-expected-usage.txt
new file mode 100644
index 000000000..96a949ba4
--- /dev/null
+++ b/cmd/policy/testdata/policy/settings-expected-usage.txt
@@ -0,0 +1,13 @@
+Usage:
+ policy settings [flags]
+
+Examples:
+circleci policy settings --owner-id 462d67f8-b232-4da4-a7de-0c86dd667d3f --enabled=true
+
+Flags:
+ --context string policy context for decision (default "config")
+ --enabled enable/disable policy decision evaluation in build pipeline
+ --owner-id string the id of the policy's owner
+
+Global Flags:
+ --policy-base-url string base url for policy api (default "https://internal.circleci.com")
diff --git a/cmd/policy/testdata/policy/test-expected-usage.txt b/cmd/policy/testdata/policy/test-expected-usage.txt
new file mode 100644
index 000000000..3981864ae
--- /dev/null
+++ b/cmd/policy/testdata/policy/test-expected-usage.txt
@@ -0,0 +1,14 @@
+Usage:
+ policy test [path] [flags]
+
+Examples:
+circleci policy test ./policies/...
+
+Flags:
+ --debug print test debug context. Sets verbose to true
+ --format string select desired format between json or junit
+ --run string select which tests to run based on regular expression
+ -v, --verbose print all tests instead of only failed tests
+
+Global Flags:
+ --policy-base-url string base url for policy api (default "https://internal.circleci.com")
diff --git a/cmd/policy/testdata/test0/config.yml b/cmd/policy/testdata/test0/config.yml
new file mode 100644
index 000000000..a43817612
--- /dev/null
+++ b/cmd/policy/testdata/test0/config.yml
@@ -0,0 +1 @@
+branch: main
\ No newline at end of file
diff --git a/cmd/policy/testdata/test0/no-valid-policy-files/should_be_ignored.txt b/cmd/policy/testdata/test0/no-valid-policy-files/should_be_ignored.txt
new file mode 100644
index 000000000..eb1dca8a3
--- /dev/null
+++ b/cmd/policy/testdata/test0/no-valid-policy-files/should_be_ignored.txt
@@ -0,0 +1 @@
+this file should be ignored while looking for policy files, based on its file extension
\ No newline at end of file
diff --git a/cmd/policy/testdata/test0/policy.rego b/cmd/policy/testdata/test0/policy.rego
new file mode 100644
index 000000000..fb722360a
--- /dev/null
+++ b/cmd/policy/testdata/test0/policy.rego
@@ -0,0 +1,5 @@
+package org
+
+policy_name["test"]
+enable_rule["branch_is_main"]
+branch_is_main = "branch must be main!" { input.branch != "main" }
diff --git a/cmd/policy/testdata/test0/subdir/meta-policy-subdir/meta-policy.rego b/cmd/policy/testdata/test0/subdir/meta-policy-subdir/meta-policy.rego
new file mode 100644
index 000000000..6b22fb441
--- /dev/null
+++ b/cmd/policy/testdata/test0/subdir/meta-policy-subdir/meta-policy.rego
@@ -0,0 +1,5 @@
+package org
+
+policy_name["meta_policy_test"]
+enable_rule["enabled"] { data.meta.vcs.branch == "main" }
+enable_rule["disabled"] { data.meta.project_id != "test-project-id" }
diff --git a/cmd/policy/testdata/test0/subdir/meta-policy-subdir/should_be_ignored.txt b/cmd/policy/testdata/test0/subdir/meta-policy-subdir/should_be_ignored.txt
new file mode 100644
index 000000000..eb1dca8a3
--- /dev/null
+++ b/cmd/policy/testdata/test0/subdir/meta-policy-subdir/should_be_ignored.txt
@@ -0,0 +1 @@
+this file should be ignored while looking for policy files, based on its file extension
\ No newline at end of file
diff --git a/cmd/policy/testdata/test1/meta.yml b/cmd/policy/testdata/test1/meta.yml
new file mode 100644
index 000000000..89cabab0a
--- /dev/null
+++ b/cmd/policy/testdata/test1/meta.yml
@@ -0,0 +1,3 @@
+project_id: test-project-id
+vcs:
+ branch: main
diff --git a/cmd/policy/testdata/test1/test.yml b/cmd/policy/testdata/test1/test.yml
new file mode 100644
index 000000000..f09fc49f6
--- /dev/null
+++ b/cmd/policy/testdata/test1/test.yml
@@ -0,0 +1 @@
+test: config
diff --git a/cmd/policy/testdata/test2/hard_fail_policy.rego b/cmd/policy/testdata/test2/hard_fail_policy.rego
new file mode 100644
index 000000000..16c730d2e
--- /dev/null
+++ b/cmd/policy/testdata/test2/hard_fail_policy.rego
@@ -0,0 +1,6 @@
+package org
+
+policy_name["hard_fail_test"]
+enable_rule["always_hard_fails"]
+hard_fail["always_hard_fails"]
+always_hard_fails = "0 is not equals 1" { 0 != 1 }
diff --git a/cmd/policy/testdata/test3/runtime_error_policy.rego b/cmd/policy/testdata/test3/runtime_error_policy.rego
new file mode 100644
index 000000000..86d3adc36
--- /dev/null
+++ b/cmd/policy/testdata/test3/runtime_error_policy.rego
@@ -0,0 +1,8 @@
+package org
+
+policy_name["some_name"]
+
+enable_rule["some_rule"]
+
+some_rule = false
+some_rule = true
\ No newline at end of file
diff --git a/cmd/policy/testdata/test_policies/policy.rego b/cmd/policy/testdata/test_policies/policy.rego
new file mode 100644
index 000000000..be57316cd
--- /dev/null
+++ b/cmd/policy/testdata/test_policies/policy.rego
@@ -0,0 +1,7 @@
+package org
+
+policy_name["test"]
+
+enable_rule["fail_if_not_main"]
+
+fail_if_not_main = "branch must be main!" { data.meta.vcs.branch != "main" }
diff --git a/cmd/policy/testdata/test_policies/policy_test.yaml b/cmd/policy/testdata/test_policies/policy_test.yaml
new file mode 100644
index 000000000..e351025c9
--- /dev/null
+++ b/cmd/policy/testdata/test_policies/policy_test.yaml
@@ -0,0 +1,19 @@
+test_main:
+ meta:
+ vcs:
+ branch: main
+ decision: &root_decision
+ status: PASS
+ enabled_rules:
+ - fail_if_not_main
+
+test_feature:
+ meta:
+ vcs:
+ branch: feature
+ decision:
+ <<: *root_decision
+ status: SOFT_FAIL
+ soft_failures:
+ - rule: fail_if_not_main
+ reason: branch must be main!
diff --git a/cmd/policy/usage_test.go b/cmd/policy/usage_test.go
new file mode 100644
index 000000000..37b4a250e
--- /dev/null
+++ b/cmd/policy/usage_test.go
@@ -0,0 +1,28 @@
+package policy
+
+import (
+ "fmt"
+ "testing"
+
+ "gotest.tools/v3/golden"
+
+ "github.com/spf13/cobra"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+)
+
+func TestUsage(t *testing.T) {
+ preRunE := func(cmd *cobra.Command, args []string) error { return nil }
+ cmd := NewCommand(&settings.Config{}, preRunE)
+ testSubCommandUsage(t, cmd.Name(), cmd)
+}
+
+func testSubCommandUsage(t *testing.T, prefix string, parent *cobra.Command) {
+ t.Helper()
+ t.Run(parent.Name(), func(t *testing.T) {
+ golden.Assert(t, parent.UsageString(), fmt.Sprintf("%s-expected-usage.txt", prefix))
+ for _, cmd := range parent.Commands() {
+ testSubCommandUsage(t, fmt.Sprintf("%s/%s", prefix, cmd.Name()), cmd)
+ }
+ })
+}
diff --git a/cmd/project/dlc.go b/cmd/project/dlc.go
new file mode 100644
index 000000000..92bf0e4d3
--- /dev/null
+++ b/cmd/project/dlc.go
@@ -0,0 +1,59 @@
+package project
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/CircleCI-Public/circleci-cli/api/dl"
+ projectapi "github.com/CircleCI-Public/circleci-cli/api/project"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+)
+
+func newProjectDLCCommand(config *settings.Config, ops *projectOpts, preRunE validator.Validator) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "dlc",
+ Short: "Manage dlc for projects",
+ }
+
+ purgeCommand := &cobra.Command{
+ Short: "Purge DLC for a project",
+ Use: "purge ",
+ PreRunE: preRunE,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ dlClient, err := dl.NewDlRestClient(*config)
+ if err != nil {
+ if dl.IsCloudOnlyErr(err) {
+ cmd.SilenceUsage = true
+ }
+ return err
+ }
+
+ return dlcPurge(cmd, ops.projectClient, dlClient, args[0], args[1], args[2])
+ },
+ Args: cobra.ExactArgs(3),
+ }
+
+ cmd.AddCommand(purgeCommand)
+ return cmd
+}
+
+func dlcPurge(cmd *cobra.Command, projClient projectapi.ProjectClient, dlClient dl.DlClient, vcsType, orgName, projName string) error {
+ // first we need to work out the project id
+ projectInfo, err := projClient.ProjectInfo(vcsType, orgName, projName)
+ if err != nil {
+ return err
+ }
+ projectId := projectInfo.Id
+
+ // now we issue the purge request
+ err = dlClient.PurgeDLC(projectId)
+ if err != nil {
+ if dl.IsGoneErr(err) {
+ cmd.SilenceUsage = true
+ }
+ return err
+ }
+
+ cmd.Println("Purged DLC for project")
+ return nil
+}
diff --git a/cmd/project/environment_variable.go b/cmd/project/environment_variable.go
new file mode 100644
index 000000000..aded40cac
--- /dev/null
+++ b/cmd/project/environment_variable.go
@@ -0,0 +1,105 @@
+package project
+
+import (
+ "fmt"
+ "strings"
+
+ projectapi "github.com/CircleCI-Public/circleci-cli/api/project"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
+ "github.com/olekukonko/tablewriter"
+ "github.com/spf13/cobra"
+)
+
+func newProjectEnvironmentVariableCommand(ops *projectOpts, preRunE validator.Validator) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "secret",
+ Short: "Operate on environment variables of projects",
+ }
+
+ listVarsCommand := &cobra.Command{
+ Short: "List all environment variables of a project",
+ Use: "list ",
+ PreRunE: preRunE,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return listProjectEnvironmentVariables(cmd, ops.projectClient, args[0], args[1], args[2])
+ },
+ Args: cobra.ExactArgs(3),
+ }
+
+ var envValue string
+ createVarCommand := &cobra.Command{
+ Short: "Create an environment variable of a project. The value is read from stdin.",
+ Use: "create ",
+ PreRunE: preRunE,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return createProjectEnvironmentVariable(cmd, ops.projectClient, ops.reader, args[0], args[1], args[2], args[3], envValue)
+ },
+ Args: cobra.ExactArgs(4),
+ }
+
+ createVarCommand.Flags().StringVar(&envValue, "env-value", "", "An environment variable value to be created. You can also pass it by stdin without this option.")
+
+ cmd.AddCommand(listVarsCommand)
+ cmd.AddCommand(createVarCommand)
+ return cmd
+}
+
+func listProjectEnvironmentVariables(cmd *cobra.Command, client projectapi.ProjectClient, vcsType, orgName, projName string) error {
+ envVars, err := client.ListAllEnvironmentVariables(vcsType, orgName, projName)
+ if err != nil {
+ return err
+ }
+
+ table := tablewriter.NewWriter(cmd.OutOrStdout())
+
+ table.SetHeader([]string{"Environment Variable", "Value"})
+
+ for _, envVar := range envVars {
+ table.Append([]string{envVar.Name, envVar.Value})
+ }
+ table.Render()
+
+ return nil
+}
+
+func createProjectEnvironmentVariable(cmd *cobra.Command, client projectapi.ProjectClient, r UserInputReader, vcsType, orgName, projName, name, value string) error {
+ if value == "" {
+ val, err := r.ReadSecretString("Enter an environment variable value and press enter")
+ if err != nil {
+ return err
+ }
+ if val == "" {
+ return fmt.Errorf("the environment variable value must not be empty")
+ }
+ value = val
+ }
+ value = strings.Trim(value, "\r\n")
+
+ existV, err := client.GetEnvironmentVariable(vcsType, orgName, projName, name)
+ if err != nil {
+ return err
+ }
+ if existV != nil {
+ msg := fmt.Sprintf("The environment variable name=%s value=%s already exists. Do you overwrite it?", existV.Name, existV.Value)
+ if !r.AskConfirm(msg) {
+ fmt.Fprintln(cmd.OutOrStdout(), "Canceled")
+ return nil
+ }
+ }
+
+ v, err := client.CreateEnvironmentVariable(vcsType, orgName, projName, projectapi.ProjectEnvironmentVariable{
+ Name: name,
+ Value: value,
+ })
+ if err != nil {
+ return err
+ }
+
+ table := tablewriter.NewWriter(cmd.OutOrStdout())
+
+ table.SetHeader([]string{"Environment Variable", "Value"})
+ table.Append([]string{v.Name, v.Value})
+ table.Render()
+
+ return nil
+}
diff --git a/cmd/project/project.go b/cmd/project/project.go
new file mode 100644
index 000000000..d7673c00a
--- /dev/null
+++ b/cmd/project/project.go
@@ -0,0 +1,76 @@
+package project
+
+import (
+ "github.com/spf13/cobra"
+
+ projectapi "github.com/CircleCI-Public/circleci-cli/api/project"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
+ "github.com/CircleCI-Public/circleci-cli/prompt"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+)
+
+// UserInputReader displays a message and reads a user input value
+type UserInputReader interface {
+ ReadSecretString(msg string) (string, error)
+ AskConfirm(msg string) bool
+}
+
+type projectOpts struct {
+ projectClient projectapi.ProjectClient
+ reader UserInputReader
+}
+
+// ProjectOption configures a command created by NewProjectCommand
+type ProjectOption interface {
+ apply(*projectOpts)
+}
+
+type promptReader struct{}
+
+func (p promptReader) ReadSecretString(msg string) (string, error) {
+ return prompt.ReadSecretStringFromUser(msg)
+}
+
+func (p promptReader) AskConfirm(msg string) bool {
+ return prompt.AskUserToConfirm(msg)
+}
+
+// NewProjectCommand generates a cobra command for managing projects
+func NewProjectCommand(config *settings.Config, preRunE validator.Validator, opts ...ProjectOption) *cobra.Command {
+ pos := projectOpts{
+ reader: &promptReader{},
+ }
+ for _, o := range opts {
+ o.apply(&pos)
+ }
+ command := &cobra.Command{
+ Use: "project",
+ Short: "Operate on projects",
+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+ client, err := projectapi.NewProjectRestClient(*config)
+ if err != nil {
+ return err
+ }
+ pos.projectClient = client
+ return nil
+ },
+ }
+
+ command.AddCommand(newProjectEnvironmentVariableCommand(&pos, preRunE))
+ command.AddCommand(newProjectDLCCommand(config, &pos, preRunE))
+
+ return command
+}
+
+type customReaderProjectOption struct {
+ r UserInputReader
+}
+
+func (c customReaderProjectOption) apply(opts *projectOpts) {
+ opts.reader = c.r
+}
+
+// CustomReader returns a ProjectOption that sets a given UserInputReader to a project command
+func CustomReader(r UserInputReader) ProjectOption {
+ return customReaderProjectOption{r}
+}
diff --git a/cmd/project/project_test.go b/cmd/project/project_test.go
new file mode 100644
index 000000000..1706a1ba2
--- /dev/null
+++ b/cmd/project/project_test.go
@@ -0,0 +1,445 @@
+package project_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/olekukonko/tablewriter"
+ "github.com/spf13/cobra"
+ "gotest.tools/v3/assert"
+
+ "github.com/CircleCI-Public/circleci-cli/cmd/project"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+)
+
+const (
+ vcsType = "github"
+ orgName = "test-org"
+ projectName = "test-project"
+)
+
+func tableString(header []string, rows [][]string) string {
+ res := &strings.Builder{}
+ table := tablewriter.NewWriter(res)
+ table.SetHeader(header)
+ for _, r := range rows {
+ table.Append(r)
+ }
+ table.Render()
+ return res.String()
+}
+
+func equalJSON(j1, j2 string) (bool, error) {
+ var j1i, j2i interface{}
+ if err := json.Unmarshal([]byte(j1), &j1i); err != nil {
+ return false, fmt.Errorf("failed to convert in equalJSON from '%s': %w", j1, err)
+ }
+ if err := json.Unmarshal([]byte(j2), &j2i); err != nil {
+ return false, fmt.Errorf("failed to convert in equalJSON from '%s': %w", j2, err)
+ }
+ return reflect.DeepEqual(j1i, j2i), nil
+}
+
+func getListProjectsArg() []string {
+ return []string{
+ "secret",
+ "list",
+ vcsType,
+ orgName,
+ projectName,
+ }
+}
+
+func TestListSecrets(t *testing.T) {
+ var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), fmt.Sprintf("/project/%s/%s/%s/envvar", vcsType, orgName, projectName))
+ response := `{
+ "items": [{
+ "name": "foo",
+ "value": "xxxx1234"
+ }],
+ "next_page_token": ""
+ }`
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(response))
+ assert.NilError(t, err)
+ }
+ server := httptest.NewServer(handler)
+ defer server.Close()
+
+ cmd, stdout, _ := scaffoldCMD(
+ server.URL,
+ func(cmd *cobra.Command, args []string) error {
+ return nil
+ },
+ )
+ cmd.SetArgs(getListProjectsArg())
+ err := cmd.Execute()
+ assert.NilError(t, err)
+
+ expect := tableString(
+ []string{"Environment Variable", "Value"},
+ [][]string{{"foo", "xxxx1234"}},
+ )
+ res := stdout.String()
+ assert.Equal(t, res, expect)
+}
+
+func TestListSecretsErrorWithValidator(t *testing.T) {
+ const errorMsg = "validator error"
+ var handler http.HandlerFunc = func(_ http.ResponseWriter, _ *http.Request) {}
+ server := httptest.NewServer(handler)
+ defer server.Close()
+
+ cmd, _, _ := scaffoldCMD(
+ server.URL,
+ func(_ *cobra.Command, _ []string) error {
+ return fmt.Errorf(errorMsg)
+ },
+ )
+ cmd.SetArgs(getListProjectsArg())
+ err := cmd.Execute()
+ assert.Error(t, err, errorMsg)
+}
+
+func TestListSecretsErrorWithAPIResponse(t *testing.T) {
+ const errorMsg = "api error"
+ var handler http.HandlerFunc = func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, err := w.Write([]byte(fmt.Sprintf(`{"message": "%s"}`, errorMsg)))
+ assert.NilError(t, err)
+ }
+ server := httptest.NewServer(handler)
+ defer server.Close()
+
+ cmd, _, _ := scaffoldCMD(
+ server.URL,
+ func(cmd *cobra.Command, args []string) error {
+ return nil
+ },
+ )
+ cmd.SetArgs(getListProjectsArg())
+ err := cmd.Execute()
+ assert.Error(t, err, errorMsg)
+}
+
+type testCreateSecretArgs struct {
+ variableVal string // ignored if --env-value flag is contained
+ statusCodeGet int
+ statusCodePost int // ignored if overwriting is canceled
+ isOverwrite bool // ignored if statusCodeGet is http.StatusNotFound
+ extraArgs []string
+}
+
+func TestCreateSecret(t *testing.T) {
+ const (
+ variableVal = "testvar1234"
+ variableKey = "foo"
+ )
+ tests := []struct {
+ name string
+ args testCreateSecretArgs
+ want string
+ wantErr bool
+ }{
+ {
+ name: "Create successfully without an existing key",
+ args: testCreateSecretArgs{
+ variableVal: variableVal,
+ statusCodeGet: http.StatusNotFound,
+ statusCodePost: http.StatusOK,
+ extraArgs: []string{variableKey},
+ },
+ want: tableString(
+ []string{"Environment Variable", "Value"},
+ [][]string{{"foo", "xxxx1234"}},
+ ),
+ },
+ {
+ name: "Overwrite successfully with an existing key",
+ args: testCreateSecretArgs{
+ variableVal: variableVal,
+ statusCodeGet: http.StatusOK,
+ statusCodePost: http.StatusOK,
+ isOverwrite: true,
+ extraArgs: []string{variableKey},
+ },
+ want: tableString(
+ []string{"Environment Variable", "Value"},
+ [][]string{{"foo", "xxxx1234"}},
+ ),
+ },
+ {
+ name: "Cancel overwriting an existing key",
+ args: testCreateSecretArgs{
+ variableVal: variableVal,
+ statusCodeGet: http.StatusOK,
+ isOverwrite: false,
+ extraArgs: []string{variableKey},
+ },
+ want: fmt.Sprintln("Canceled"),
+ },
+ {
+ name: "Pass a variable through a commandline argument",
+ args: testCreateSecretArgs{
+ statusCodeGet: http.StatusNotFound,
+ statusCodePost: http.StatusOK,
+ extraArgs: []string{variableKey, "--env-value", variableVal},
+ },
+ want: tableString(
+ []string{"Environment Variable", "Value"},
+ [][]string{{"foo", "xxxx1234"}},
+ ),
+ },
+ {
+ name: "Handle an error request from GetEnvironmentVariable",
+ args: testCreateSecretArgs{
+ variableVal: variableVal,
+ statusCodeGet: http.StatusInternalServerError,
+ statusCodePost: http.StatusOK,
+ extraArgs: []string{variableKey},
+ },
+ wantErr: true,
+ },
+ {
+ name: "Handle an error request from CreateEnvironmentVariable",
+ args: testCreateSecretArgs{
+ variableVal: variableVal,
+ statusCodeGet: http.StatusNotFound,
+ statusCodePost: http.StatusInternalServerError,
+ extraArgs: []string{variableKey},
+ },
+ wantErr: true,
+ },
+ {
+ name: "The process should be rejected if the passed value is empty",
+ args: testCreateSecretArgs{
+ variableVal: "",
+ statusCodeGet: http.StatusNotFound,
+ statusCodePost: http.StatusOK,
+ extraArgs: []string{variableKey},
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := testCreateSecret(t, &tt.args)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Create secret command: error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("Create secret command: got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+type testInputReader struct {
+ secret string
+ yesNo bool
+}
+
+func (s testInputReader) ReadSecretString(msg string) (string, error) {
+ return s.secret, nil
+}
+
+func (s testInputReader) AskConfirm(msg string) bool {
+ return s.yesNo
+}
+
+func testCreateSecret(t *testing.T, args *testCreateSecretArgs) (string, error) {
+ const apiResponseBody = `{
+ "name": "foo",
+ "value": "xxxx1234"
+ }`
+ var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ assert.Equal(t, r.URL.String(), fmt.Sprintf("/project/%s/%s/%s/envvar/foo", vcsType, orgName, projectName))
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(args.statusCodeGet)
+ if args.statusCodeGet == http.StatusOK {
+ _, err := w.Write([]byte(apiResponseBody))
+ assert.NilError(t, err)
+ }
+ case "POST":
+ expect := `{
+ "name": "foo",
+ "value": "testvar1234"
+ }`
+ assert.Equal(t, r.URL.String(), fmt.Sprintf("/project/%s/%s/%s/envvar", vcsType, orgName, projectName))
+ isRequestBodyValid(t, r, expect)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(args.statusCodePost)
+ if args.statusCodePost == http.StatusOK {
+ _, err := w.Write([]byte(apiResponseBody))
+ assert.NilError(t, err)
+ }
+ }
+ }
+
+ server := httptest.NewServer(handler)
+ defer server.Close()
+
+ cmd, stdout, _ := scaffoldCMD(
+ server.URL,
+ func(cmd *cobra.Command, args []string) error {
+ return nil
+ },
+ project.CustomReader(testInputReader{
+ secret: args.variableVal,
+ yesNo: args.isOverwrite,
+ }),
+ )
+ cmd.SetArgs(append(getCreateSecretArgBase(), args.extraArgs...))
+
+ err := cmd.Execute()
+ if err != nil {
+ return "", err
+ }
+
+ return stdout.String(), nil
+}
+
+func getCreateSecretArgBase() []string {
+ return []string{
+ "secret",
+ "create",
+ vcsType,
+ orgName,
+ projectName,
+ }
+}
+
+func isRequestBodyValid(t *testing.T, r *http.Request, expect string) {
+ b, err := io.ReadAll(r.Body)
+ assert.NilError(t, err)
+ eq, err := equalJSON(string(b), expect)
+ assert.NilError(t, err)
+ assert.Equal(t, eq, true)
+}
+
+func scaffoldCMD(
+ baseURL string,
+ validator validator.Validator,
+ opts ...project.ProjectOption,
+) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
+ config := &settings.Config{
+ Token: "testtoken",
+ HTTPClient: http.DefaultClient,
+ Host: baseURL,
+ DlHost: baseURL,
+ }
+ cmd := project.NewProjectCommand(config, validator, opts...)
+
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ cmd.SetOut(stdout)
+ cmd.SetErr(stderr)
+
+ return cmd, stdout, stderr
+}
+
+func TestDLCPurge(t *testing.T) {
+ noValidator := func(_ *cobra.Command, _ []string) error {
+ return nil
+ }
+
+ t.Run("Happy path", func(t *testing.T) {
+ handlers := []http.HandlerFunc{
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "GET")
+ assert.Equal(t, r.URL.String(), fmt.Sprintf("/project/%s/%s/%s", "gh", "whom", "what"))
+ assert.DeepEqual(t, r.Header["Circle-Token"], []string{"testtoken"})
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(`{ "id": "this-is-the-project-id" }`))
+ assert.NilError(t, err)
+ },
+ func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, r.Method, "DELETE")
+ assert.Equal(t, r.URL.String(), "/private/output/project/this-is-the-project-id/dlc")
+ assert.DeepEqual(t, r.Header["Circle-Token"], []string{"testtoken"})
+ w.WriteHeader(http.StatusOK)
+ },
+ }
+ var h http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
+ var handler http.HandlerFunc
+ handler, handlers = handlers[0], handlers[1:]
+ handler(w, r)
+ }
+ server := httptest.NewServer(h)
+ defer server.Close()
+ cmd, outbuf, errbuf := scaffoldCMD(server.URL, noValidator)
+ cmd.SetArgs([]string{"dlc", "purge", "gh", "whom", "what"})
+ err := cmd.Execute()
+ assert.NilError(t, err)
+ assert.Equal(t, outbuf.String(), "Purged DLC for project\n")
+ assert.Equal(t, errbuf.String(), "")
+ })
+ t.Run("Gone", func(t *testing.T) {
+ handlers := []http.HandlerFunc{
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, err := w.Write([]byte(`{ "id": "this-is-the-project-id" }`))
+ assert.NilError(t, err)
+ },
+ func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusGone)
+ },
+ }
+ var h http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
+ var handler http.HandlerFunc
+ handler, handlers = handlers[0], handlers[1:]
+ handler(w, r)
+ }
+ server := httptest.NewServer(h)
+ defer server.Close()
+ cmd, outbuf, errbuf := scaffoldCMD(server.URL, noValidator)
+ cmd.SetArgs([]string{"dlc", "purge", "gh", "whom", "what"})
+ err := cmd.Execute()
+ assert.Assert(t, err != nil)
+
+ assert.Equal(t, outbuf.String(), "")
+ assert.Equal(t, errbuf.String(),
+ "Error: No longer supported.\n"+
+ "This functionality is no longer supported by this version of the circleci CLI.\n"+
+ "Please upgrade to the latest version of the circleci CLI.\n",
+ )
+ })
+ t.Run("Not cloud", func(t *testing.T) {
+ // (this test doesn't use httptest because it's testing a
+ // misconfiguration and doesn't get as far as making a http request)
+ cmd := project.NewProjectCommand(&settings.Config{
+ Host: "some custom value but dlhost is not set",
+ HTTPClient: http.DefaultClient,
+ }, noValidator)
+ outbuf := new(bytes.Buffer)
+ errbuf := new(bytes.Buffer)
+ cmd.SetOut(outbuf)
+ cmd.SetErr(errbuf)
+ cmd.SetArgs([]string{"dlc", "purge", "gh", "whom", "what"})
+ err := cmd.Execute()
+ assert.Assert(t, err != nil)
+ assert.Equal(t, outbuf.String(), "")
+ assert.Equal(t, errbuf.String(),
+ "Error: Misconfiguration.\n"+
+ "You have configured a custom API endpoint host for the circleci CLI.\n"+
+ "However, this functionality is only supported on circleci.com API endpoints.\n",
+ )
+ })
+}
diff --git a/cmd/query.go b/cmd/query.go
index 14a833642..e871426c6 100644
--- a/cmd/query.go
+++ b/cmd/query.go
@@ -3,7 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
- "io/ioutil"
+ "io"
"os"
"github.com/CircleCI-Public/circleci-cli/api/graphql"
@@ -51,9 +51,9 @@ func query(opts queryOptions) error {
var resp map[string]interface{}
if opts.args[0] == "-" {
- q, err = ioutil.ReadAll(os.Stdin)
+ q, err = io.ReadAll(os.Stdin)
} else {
- q, err = ioutil.ReadFile(opts.args[0])
+ q, err = os.ReadFile(opts.args[0])
}
if err != nil {
diff --git a/cmd/root.go b/cmd/root.go
index 3b1a7d276..942278c69 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -2,22 +2,28 @@ package cmd
import (
"fmt"
+ "log"
"os"
"strings"
- "github.com/spf13/cobra"
-
"github.com/CircleCI-Public/circleci-cli/api/header"
+ "github.com/CircleCI-Public/circleci-cli/cmd/info"
+ "github.com/CircleCI-Public/circleci-cli/cmd/policy"
+ "github.com/CircleCI-Public/circleci-cli/cmd/project"
"github.com/CircleCI-Public/circleci-cli/cmd/runner"
"github.com/CircleCI-Public/circleci-cli/data"
"github.com/CircleCI-Public/circleci-cli/md_docs"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/CircleCI-Public/circleci-cli/version"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/spf13/cobra"
+ "golang.org/x/term"
)
var defaultEndpoint = "graphql-unstable"
var defaultHost = "https://circleci.com"
var defaultRestEndpoint = "api/v2"
+var trueString = "true"
// rootCmd is used internally and global to the package but not exported
// therefore we can use it in other commands, like `usage`
@@ -86,6 +92,7 @@ Global Flags:
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
+
`
// MakeCommands creates the top level commands
@@ -103,15 +110,19 @@ func MakeCommands() *cobra.Command {
panic(err)
}
- loaded, err := data.LoadData()
- if err != nil {
- panic(err)
+ rootOptions.Data = &data.Data
+
+ helpWidth := getHelpWidth()
+ // CircleCI Logo will only appear with enough window width
+ longHelp := ""
+ if helpWidth > 85 {
+ longHelp = rootHelpLong()
}
- rootOptions.Data = loaded
rootCmd = &cobra.Command{
- Use: "circleci",
- Long: rootHelpLong(rootOptions),
+ Use: "circleci",
+ Long: longHelp,
+ Short: rootHelpShort(rootOptions),
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
return rootCmdPreRun(rootOptions)
},
@@ -121,6 +132,11 @@ func MakeCommands() *cobra.Command {
cobra.AddTemplateFunc("HasAnnotations", hasAnnotations)
cobra.AddTemplateFunc("PositionalArgs", md_docs.PositionalArgs)
cobra.AddTemplateFunc("FormatPositionalArg", md_docs.FormatPositionalArg)
+
+ if os.Getenv("TESTING") != trueString {
+ helpCmd := helpCmd{width: helpWidth}
+ rootCmd.SetHelpFunc(helpCmd.helpTemplate)
+ }
rootCmd.SetUsageTemplate(usageTemplate)
rootCmd.DisableAutoGenTag = true
@@ -131,6 +147,7 @@ func MakeCommands() *cobra.Command {
rootCmd.AddCommand(newOpenCommand())
rootCmd.AddCommand(newTestsCommand())
rootCmd.AddCommand(newContextCommand(rootOptions))
+ rootCmd.AddCommand(project.NewProjectCommand(rootOptions, validator))
rootCmd.AddCommand(newQueryCommand(rootOptions))
rootCmd.AddCommand(newConfigCommand(rootOptions))
rootCmd.AddCommand(newOrbCommand(rootOptions))
@@ -142,6 +159,7 @@ func MakeCommands() *cobra.Command {
rootCmd.AddCommand(newSetupCommand(rootOptions))
rootCmd.AddCommand(followProjectCommand(rootOptions))
+ rootCmd.AddCommand(policy.NewCommand(rootOptions, validator))
if isUpdateIncluded(version.PackageManager()) {
rootCmd.AddCommand(newUpdateCommand(rootOptions))
@@ -150,11 +168,13 @@ func MakeCommands() *cobra.Command {
}
rootCmd.AddCommand(newNamespaceCommand(rootOptions))
+ rootCmd.AddCommand(info.NewInfoCommand(rootOptions, validator))
rootCmd.AddCommand(newUsageCommand(rootOptions))
rootCmd.AddCommand(newStepCommand(rootOptions))
rootCmd.AddCommand(newSwitchCommand(rootOptions))
rootCmd.AddCommand(newAdminCommand(rootOptions))
rootCmd.AddCommand(newCompletionCommand())
+ rootCmd.AddCommand(newEnvCmd())
flags := rootCmd.PersistentFlags()
@@ -279,21 +299,137 @@ func isUpdateIncluded(packageManager string) bool {
}
}
-func rootHelpLong(config *settings.Config) string {
- long := `Use CircleCI from the command line.
+func skipUpdateByDefault() bool {
+ return os.Getenv("CI") == trueString || os.Getenv("CIRCLECI_CLI_SKIP_UPDATE_CHECK") == trueString
+}
+/**************** Help Menu Functions ****************/
+
+// rootHelpLong creates content for the long field in the command
+func rootHelpLong() string {
+ long := `
+ /?? /?? /??
+ |__/ | ?? |__/
+ /???????? /??????? /?? /?????? /???????| ?? /?????? /??????? /??
+ /_______/?? /??_____/| ?? /??__ ?? /??_____/| ?? /??__ ?? /??_____/| ??
+ /?? | ?? | ?? | ??| ?? \__/| ?? | ??| ???????? | ?? | ??
+ |__/ | ?? | ?? | ??| ?? | ?? | ??| ??_____/ | ?? | ??
+ /???????? | ???????| ??| ?? | ???????| ??| ??????? | ???????| ??
+ /________/ \_______/|__/|__/ \_______/|__/ \_______/ \_______/|__/`
+ return long
+}
+
+// rootHelpShort creates content for the short feild in the command
+func rootHelpShort(config *settings.Config) string {
+ short := `Use CircleCI from the command line.
This project is the seed for CircleCI's new command-line application.`
// We should only print this for cloud users
if config.Host != defaultHost {
- return long
+ return short
}
return fmt.Sprintf(`%s
+For more help, see the documentation here: %s`, short, config.Data.Links.CLIDocs)
+}
-For more help, see the documentation here: %s`, long, config.Data.Links.CLIDocs)
+type helpCmd struct {
+ width int
}
-func skipUpdateByDefault() bool {
- return os.Getenv("CI") == "true" || os.Getenv("CIRCLECI_CLI_SKIP_UPDATE_CHECK") == "true"
+// helpTemplate Building a custom help template with more finesse and pizazz
+func (helpCmd *helpCmd) helpTemplate(cmd *cobra.Command, s []string) {
+
+ /***Styles ***/
+ titleStyle := lipgloss.NewStyle().Bold(true).
+ Foreground(lipgloss.AdaptiveColor{Light: `#003740`, Dark: `#3B6385`}).
+ BorderBottom(true).
+ Margin(1, 0, 1, 0).
+ Padding(0, 1, 0, 1).Align(lipgloss.Center)
+ subCmdStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.AdaptiveColor{Light: `#161616`, Dark: `#FFFFFF`}).
+ Padding(0, 4, 0, 4).Align(lipgloss.Left)
+ subCmdInfoStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.AdaptiveColor{Light: `#161616`, Dark: `#FFFFFF`}).Bold(true)
+ textStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.AdaptiveColor{Light: `#161616`, Dark: `#FFFFFF`}).Align(lipgloss.Left).Margin(0).Padding(0)
+
+ /** Building Usage String **/
+ usageText := strings.Builder{}
+
+ //get command path
+ usageText.WriteString(titleStyle.Render(cmd.CommandPath()))
+
+ //get command short or long
+ cmdDesc := titleStyle.Render(cmd.Long)
+ if strings.TrimSpace(cmdDesc) == "" || cmd.Name() == "circleci" {
+ if cmd.Name() == "circleci" {
+ cmdDesc += "\n\n" //add some spaces for circleci command
+ }
+ cmdDesc += subCmdStyle.Render(cmd.Short)
+ }
+ usageText.WriteString(cmdDesc + "\n")
+
+ if len(cmd.Aliases) > 0 {
+ aliases := titleStyle.Render("Aliases:")
+ aliases += textStyle.Render(cmd.NameAndAliases())
+ usageText.WriteString(aliases + "\n")
+ }
+
+ if cmd.Runnable() {
+ usage := titleStyle.Render("Usage:")
+ usage += textStyle.Render(cmd.UseLine())
+ usageText.WriteString(usage + "\n")
+ }
+
+ if cmd.HasExample() {
+ examples := titleStyle.Render("Example:")
+ examples += textStyle.Render(cmd.Example)
+ usageText.WriteString(examples + "\n")
+ }
+
+ if cmd.HasAvailableSubCommands() {
+ subCmds := cmd.Commands()
+ subTitle := titleStyle.Render("Available Commands:")
+ subs := ""
+ for i := range subCmds {
+ if subCmds[i].IsAvailableCommand() {
+ subs += subCmdStyle.Render(subCmds[i].Name()) + subCmdInfoStyle.
+ PaddingLeft(subCmds[i].NamePadding()-len(subCmds[i].Name())+1).Render(subCmds[i].Short) + "\n"
+ }
+ }
+ usageText.WriteString(lipgloss.JoinVertical(lipgloss.Left, subTitle, subs))
+ }
+
+ if cmd.HasAvailableLocalFlags() {
+ flags := titleStyle.Render("Local Flags:")
+ flags += textStyle.Render("\n" + cmd.LocalFlags().FlagUsages())
+ usageText.WriteString(flags)
+ }
+ if cmd.HasAvailableInheritedFlags() {
+ flags := titleStyle.Render("Global Flags:")
+ flags += textStyle.Render("\n" + cmd.InheritedFlags().FlagUsages())
+ usageText.WriteString(flags)
+ }
+
+ //Border styles
+ borderStyle := lipgloss.NewStyle().
+ Padding(0, 1, 0, 1).
+ Width(helpCmd.width - 2).
+ BorderForeground(lipgloss.AdaptiveColor{Light: `#3B6385`, Dark: `#47A359`}).
+ Border(lipgloss.ThickBorder())
+
+ log.Println("\n" + borderStyle.Render(usageText.String()+"\n"))
+}
+
+func getHelpWidth() int {
+ const defaultHelpWidth = 122
+ if !term.IsTerminal(0) {
+ return defaultHelpWidth
+ }
+ w, _, err := term.GetSize(0)
+ if err == nil && w < defaultHelpWidth {
+ return w
+ }
+ return defaultHelpWidth
}
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 9a9ff0cf4..def65ae10 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -3,19 +3,20 @@ package cmd_test
import (
"os/exec"
- "github.com/CircleCI-Public/circleci-cli/clitest"
- "github.com/CircleCI-Public/circleci-cli/cmd"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gbytes"
"github.com/onsi/gomega/gexec"
+
+ "github.com/CircleCI-Public/circleci-cli/clitest"
+ "github.com/CircleCI-Public/circleci-cli/cmd"
)
var _ = Describe("Root", func() {
Describe("subcommands", func() {
It("can create commands", func() {
commands := cmd.MakeCommands()
- Expect(len(commands.Commands())).To(Equal(20))
+ Expect(len(commands.Commands())).To(Equal(24))
})
})
diff --git a/cmd/runner/instance.go b/cmd/runner/instance.go
index 98fb1c3c4..28f260937 100644
--- a/cmd/runner/instance.go
+++ b/cmd/runner/instance.go
@@ -8,9 +8,10 @@ import (
"github.com/spf13/cobra"
"github.com/CircleCI-Public/circleci-cli/api/runner"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
)
-func newRunnerInstanceCommand(o *runnerOpts, preRunE validator) *cobra.Command {
+func newRunnerInstanceCommand(o *runnerOpts, preRunE validator.Validator) *cobra.Command {
cmd := &cobra.Command{
Use: "instance",
Short: "Operate on runner instances",
diff --git a/cmd/runner/resource_class.go b/cmd/runner/resource_class.go
index 9a7a513bd..1a727737e 100644
--- a/cmd/runner/resource_class.go
+++ b/cmd/runner/resource_class.go
@@ -7,9 +7,10 @@ import (
"github.com/spf13/cobra"
"github.com/CircleCI-Public/circleci-cli/api/runner"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
)
-func newResourceClassCommand(o *runnerOpts, preRunE validator) *cobra.Command {
+func newResourceClassCommand(o *runnerOpts, preRunE validator.Validator) *cobra.Command {
cmd := &cobra.Command{
Use: "resource-class",
Short: "Operate on runner resource-classes",
@@ -47,7 +48,8 @@ func newResourceClassCommand(o *runnerOpts, preRunE validator) *cobra.Command {
"Generate a default token")
cmd.AddCommand(createCmd)
- cmd.AddCommand(&cobra.Command{
+ forceDelete := false
+ deleteCmd := &cobra.Command{
Use: "delete ",
Short: "Delete a resource-class",
Aliases: []string{"rm"},
@@ -58,9 +60,12 @@ func newResourceClassCommand(o *runnerOpts, preRunE validator) *cobra.Command {
if err != nil {
return err
}
- return o.r.DeleteResourceClass(rc.ID)
+ return o.r.DeleteResourceClass(rc.ID, forceDelete)
},
- })
+ }
+ deleteCmd.PersistentFlags().BoolVarP(&forceDelete, "force", "f", false,
+ "Delete resource-class and any associated tokens")
+ cmd.AddCommand(deleteCmd)
cmd.AddCommand(&cobra.Command{
Use: "list ",
diff --git a/cmd/runner/resource_class_test.go b/cmd/runner/resource_class_test.go
index 204de1d85..df9cbbf27 100644
--- a/cmd/runner/resource_class_test.go
+++ b/cmd/runner/resource_class_test.go
@@ -120,7 +120,7 @@ func (r *runnerMock) GetResourceClassesByNamespace(namespace string) ([]runner.R
return rcs, nil
}
-func (r *runnerMock) DeleteResourceClass(id string) error {
+func (r *runnerMock) DeleteResourceClass(id string, force bool) error {
for i, rc := range r.resourceClasses {
if rc.ID == id {
r.resourceClasses = append(r.resourceClasses[:i], r.resourceClasses[i+1:]...)
diff --git a/cmd/runner/runner.go b/cmd/runner/runner.go
index 5163238e3..72424c1b6 100644
--- a/cmd/runner/runner.go
+++ b/cmd/runner/runner.go
@@ -1,10 +1,13 @@
package runner
import (
+ "strings"
+
"github.com/spf13/cobra"
"github.com/CircleCI-Public/circleci-cli/api/rest"
"github.com/CircleCI-Public/circleci-cli/api/runner"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
"github.com/CircleCI-Public/circleci-cli/settings"
)
@@ -12,18 +15,26 @@ type runnerOpts struct {
r running
}
-func NewCommand(config *settings.Config, preRunE validator) *cobra.Command {
+func NewCommand(config *settings.Config, preRunE validator.Validator) *cobra.Command {
var opts runnerOpts
cmd := &cobra.Command{
Use: "runner",
Short: "Operate on runners",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
- opts.r = runner.New(rest.New(config.Host, config.RestEndpoint, config.Token))
+ var host string
+ if strings.Contains(config.Host, "https://circleci.com") {
+ host = "https://runner.circleci.com"
+ } else {
+ host = config.Host
+ }
+ opts.r = runner.New(rest.NewFromConfig(host, config))
},
}
+
cmd.AddCommand(newResourceClassCommand(&opts, preRunE))
cmd.AddCommand(newTokenCommand(&opts, preRunE))
cmd.AddCommand(newRunnerInstanceCommand(&opts, preRunE))
+
return cmd
}
@@ -32,11 +43,9 @@ type running interface {
GetResourceClassByName(resourceClass string) (rc *runner.ResourceClass, err error)
GetNamespaceByResourceClass(resourceClass string) (ns string, err error)
GetResourceClassesByNamespace(namespace string) ([]runner.ResourceClass, error)
- DeleteResourceClass(id string) error
+ DeleteResourceClass(id string, force bool) error
CreateToken(resourceClass, nickname string) (token *runner.Token, err error)
GetRunnerTokensByResourceClass(resourceClass string) ([]runner.Token, error)
DeleteToken(id string) error
GetRunnerInstances(query string) ([]runner.RunnerInstance, error)
}
-
-type validator func(cmd *cobra.Command, args []string) error
diff --git a/cmd/runner/testdata/runner/resource-class/delete-expected-usage.txt b/cmd/runner/testdata/runner/resource-class/delete-expected-usage.txt
index 276c2cb3c..57cce717a 100644
--- a/cmd/runner/testdata/runner/resource-class/delete-expected-usage.txt
+++ b/cmd/runner/testdata/runner/resource-class/delete-expected-usage.txt
@@ -1,5 +1,8 @@
Usage:
- runner resource-class delete
+ runner resource-class delete [flags]
Aliases:
delete, rm
+
+Flags:
+ -f, --force Delete resource-class and any associated tokens
diff --git a/cmd/runner/token.go b/cmd/runner/token.go
index f4a85f5fd..4771b37e9 100644
--- a/cmd/runner/token.go
+++ b/cmd/runner/token.go
@@ -3,11 +3,12 @@ package runner
import (
"time"
+ "github.com/CircleCI-Public/circleci-cli/cmd/validator"
"github.com/olekukonko/tablewriter"
"github.com/spf13/cobra"
)
-func newTokenCommand(o *runnerOpts, preRunE validator) *cobra.Command {
+func newTokenCommand(o *runnerOpts, preRunE validator.Validator) *cobra.Command {
cmd := &cobra.Command{
Use: "token",
Short: "Operate on runner tokens",
diff --git a/cmd/setup_test.go b/cmd/setup_test.go
index 8a1c025d9..591a6197d 100644
--- a/cmd/setup_test.go
+++ b/cmd/setup_test.go
@@ -2,7 +2,7 @@ package cmd_test
import (
"fmt"
- "io/ioutil"
+ "io"
"os"
"os/exec"
"regexp"
@@ -248,7 +248,7 @@ Your configuration has been saved to %s.
file, err := os.Open(tempSettings.Config.Path)
Expect(err).ShouldNot(HaveOccurred())
- reread, err := ioutil.ReadAll(file)
+ reread, err := io.ReadAll(file)
Expect(err).ShouldNot(HaveOccurred())
Expect(string(reread)).To(Equal(`host: https://zomg.com
endpoint: graphql-unstable
diff --git a/cmd/update.go b/cmd/update.go
index 07f3be75f..c43bf33d4 100644
--- a/cmd/update.go
+++ b/cmd/update.go
@@ -4,7 +4,6 @@ import (
"fmt"
"time"
- "github.com/CircleCI-Public/circleci-cli/local"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/CircleCI-Public/circleci-cli/update"
"github.com/CircleCI-Public/circleci-cli/version"
@@ -74,7 +73,7 @@ func newUpdateCommand(config *settings.Config) *cobra.Command {
update.AddCommand(&cobra.Command{
Use: "build-agent",
Hidden: true,
- Short: "Update the build agent to the latest version",
+ Short: "This command has no effect, and is kept for backwards compatibility",
PersistentPreRun: func(_ *cobra.Command, _ []string) {
opts.cfg.SkipUpdateCheck = true
},
@@ -82,7 +81,7 @@ func newUpdateCommand(config *settings.Config) *cobra.Command {
opts.args = args
},
RunE: func(_ *cobra.Command, _ []string) error {
- return local.UpdateBuildAgent()
+ return nil
},
})
diff --git a/cmd/update_test.go b/cmd/update_test.go
index d0dba1aef..431987119 100644
--- a/cmd/update_test.go
+++ b/cmd/update_test.go
@@ -60,7 +60,7 @@ var _ = Describe("Update", func() {
tempSettings.TestServer.AppendHandlers(
ghttp.CombineHandlers(
- ghttp.VerifyRequest("GET", "/repos/CircleCI-Public/circleci-cli/releases"),
+ ghttp.VerifyRequest(http.MethodGet, "/repos/CircleCI-Public/circleci-cli/releases"),
ghttp.RespondWith(http.StatusOK, response),
),
)
@@ -132,11 +132,11 @@ var _ = Describe("Update", func() {
tempSettings.TestServer.AppendHandlers(
ghttp.CombineHandlers(
- ghttp.VerifyRequest("GET", "/repos/CircleCI-Public/circleci-cli/releases"),
+ ghttp.VerifyRequest(http.MethodGet, "/repos/CircleCI-Public/circleci-cli/releases"),
ghttp.RespondWith(http.StatusOK, response),
),
ghttp.CombineHandlers(
- ghttp.VerifyRequest("GET", "/repos/CircleCI-Public/circleci-cli/releases/assets/1"),
+ ghttp.VerifyRequest(http.MethodGet, "/repos/CircleCI-Public/circleci-cli/releases/assets/1"),
ghttp.RespondWith(http.StatusOK, assetResponse),
),
)
@@ -166,7 +166,7 @@ var _ = Describe("Update", func() {
tempSettings.TestServer.Reset()
tempSettings.TestServer.AppendHandlers(
ghttp.CombineHandlers(
- ghttp.VerifyRequest("GET", "/repos/CircleCI-Public/circleci-cli/releases"),
+ ghttp.VerifyRequest(http.MethodGet, "/repos/CircleCI-Public/circleci-cli/releases"),
ghttp.RespondWith(http.StatusForbidden, []byte("Forbidden")),
),
)
diff --git a/cmd/validator/validator.go b/cmd/validator/validator.go
new file mode 100644
index 000000000..c6661a70a
--- /dev/null
+++ b/cmd/validator/validator.go
@@ -0,0 +1,5 @@
+package validator
+
+import "github.com/spf13/cobra"
+
+type Validator func(cmd *cobra.Command, args []string) error
diff --git a/config/collaborators.go b/config/collaborators.go
new file mode 100644
index 000000000..30da51d17
--- /dev/null
+++ b/config/collaborators.go
@@ -0,0 +1,39 @@
+package config
+
+import (
+ "net/url"
+)
+
+var (
+ CollaborationsPath = "me/collaborations"
+)
+
+type CollaborationResult struct {
+ VcsTye string `json:"vcs_type"`
+ OrgSlug string `json:"slug"`
+ OrgName string `json:"name"`
+ OrgId string `json:"id"`
+ AvatarUrl string `json:"avatar_url"`
+}
+
+// GetOrgCollaborations - fetches all the collaborations for a given user.
+func (c *ConfigCompiler) GetOrgCollaborations() ([]CollaborationResult, error) {
+ req, err := c.collaboratorRestClient.NewRequest("GET", &url.URL{Path: CollaborationsPath}, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp []CollaborationResult
+ _, err = c.collaboratorRestClient.DoRequest(req, &resp)
+ return resp, err
+}
+
+// GetOrgIdFromSlug - converts a slug into an orgID.
+func GetOrgIdFromSlug(slug string, collaborations []CollaborationResult) string {
+ for _, v := range collaborations {
+ if v.OrgSlug == slug {
+ return v.OrgId
+ }
+ }
+ return ""
+}
diff --git a/config/collaborators_test.go b/config/collaborators_test.go
new file mode 100644
index 000000000..115c4f8c9
--- /dev/null
+++ b/config/collaborators_test.go
@@ -0,0 +1,43 @@
+package config
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetOrgCollaborations(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
+ }))
+ defer svr.Close()
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ t.Run("assert compiler has correct host", func(t *testing.T) {
+ assert.Equal(t, "http://"+compiler.collaboratorRestClient.BaseURL.Host, svr.URL)
+ })
+
+ t.Run("getOrgCollaborations can parse response correctly", func(t *testing.T) {
+ collabs, err := compiler.GetOrgCollaborations()
+ assert.NoError(t, err)
+ assert.Equal(t, 1, len(collabs))
+ assert.Equal(t, "circleci", collabs[0].VcsTye)
+ })
+
+ t.Run("can fetch orgID from a slug", func(t *testing.T) {
+ expected := "1234"
+ actual := GetOrgIdFromSlug("gh/test", []CollaborationResult{{OrgSlug: "gh/test", OrgId: "1234"}})
+ assert.Equal(t, expected, actual)
+ })
+
+ t.Run("returns empty if no slug match", func(t *testing.T) {
+ expected := ""
+ actual := GetOrgIdFromSlug("gh/doesntexist", []CollaborationResult{{OrgSlug: "gh/test", OrgId: "1234"}})
+ assert.Equal(t, expected, actual)
+ })
+}
diff --git a/config/commands.go b/config/commands.go
new file mode 100644
index 000000000..64504939f
--- /dev/null
+++ b/config/commands.go
@@ -0,0 +1,168 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/pkg/errors"
+ "gopkg.in/yaml.v3"
+)
+
+func printValues(values Values) {
+ // Provide a stable sort order for printed values
+ keys := make([]string, 0, len(values))
+ for k := range values {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ for _, key := range keys {
+ fmt.Fprintf(os.Stderr, "%-18s %v\n", key+":", values[key])
+ }
+
+ // Add empty newline at end
+ fmt.Fprintf(os.Stderr, "\n")
+}
+
+type ProcessConfigOpts struct {
+ ConfigPath string
+ OrgID string
+ OrgSlug string
+ PipelineParamsFilePath string
+
+ VerboseOutput bool
+}
+
+func (c *ConfigCompiler) getOrgID(
+ optsOrgID string,
+ optsOrgSlug string,
+) (string, error) {
+ if optsOrgID == "" && optsOrgSlug == "" {
+ return "", nil
+ }
+
+ var orgID string
+ if strings.TrimSpace(optsOrgID) != "" {
+ orgID = optsOrgID
+ } else {
+ orgs, err := c.GetOrgCollaborations()
+ if err != nil {
+ return "", err
+ }
+ orgID = GetOrgIdFromSlug(optsOrgSlug, orgs)
+ if orgID == "" {
+ fmt.Println("Could not fetch a valid org-id from collaborators endpoint.")
+ fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations")
+ fmt.Println("Continuing on - private orb resolution will not work as intended")
+ }
+ }
+
+ return orgID, nil
+}
+
+func (c *ConfigCompiler) ProcessConfig(opts ProcessConfigOpts) error {
+ var response *ConfigResponse
+ var params Parameters
+ var err error
+
+ if len(opts.PipelineParamsFilePath) > 0 {
+ // The 'src' value can be a filepath, or a yaml string. If the file cannot be read successfully,
+ // proceed with the assumption that the value is already valid yaml.
+ raw, err := os.ReadFile(opts.PipelineParamsFilePath)
+ if err != nil {
+ raw = []byte(opts.PipelineParamsFilePath)
+ }
+
+ err = yaml.Unmarshal(raw, ¶ms)
+ if err != nil {
+ return fmt.Errorf("invalid 'pipeline-parameters' provided: %s", err.Error())
+ }
+ }
+
+ //if no orgId provided use org slug
+ values := LocalPipelineValues()
+ if opts.VerboseOutput {
+ fmt.Fprintln(os.Stderr, "Processing config with following values:")
+ printValues(values)
+ }
+
+ orgID, err := c.getOrgID(opts.OrgID, opts.OrgSlug)
+ if err != nil {
+ return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error())
+ }
+
+ response, err = c.ConfigQuery(
+ opts.ConfigPath,
+ orgID,
+ params,
+ values,
+ )
+ if err != nil {
+ return err
+ }
+
+ if !response.Valid {
+ fmt.Println(response.Errors)
+ return errors.New("config is invalid")
+ }
+
+ fmt.Print(response.OutputYaml)
+ return nil
+}
+
+type ValidateConfigOpts struct {
+ ConfigPath string
+ OrgID string
+ OrgSlug string
+
+ IgnoreDeprecatedImages bool
+ VerboseOutput bool
+}
+
+// The arg is actually optional, in order to support compatibility with the --path flag.
+func (c *ConfigCompiler) ValidateConfig(opts ValidateConfigOpts) error {
+ var err error
+ var response *ConfigResponse
+
+ //if no orgId provided use org slug
+ values := LocalPipelineValues()
+ if opts.VerboseOutput {
+ fmt.Fprintln(os.Stderr, "Validating config with following values:")
+ printValues(values)
+ }
+
+ orgID, err := c.getOrgID(opts.OrgID, opts.OrgSlug)
+ if err != nil {
+ return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error())
+ }
+
+ response, err = c.ConfigQuery(
+ opts.ConfigPath,
+ orgID,
+ nil,
+ LocalPipelineValues(),
+ )
+ if err != nil {
+ return err
+ }
+
+ if !response.Valid {
+ fmt.Println(response.Errors)
+ return errors.New("config is invalid")
+ }
+
+ // check if a deprecated Linux VM image is being used
+ // link here to blog post when available
+ // returns an error if a deprecated image is used
+ if !opts.IgnoreDeprecatedImages {
+ err := deprecatedImageCheck(response)
+ if err != nil {
+ return err
+ }
+ }
+
+ fmt.Printf("Config file at %s is valid.\n", opts.ConfigPath)
+ return nil
+}
diff --git a/config/commands_test.go b/config/commands_test.go
new file mode 100644
index 000000000..dc6e7a16a
--- /dev/null
+++ b/config/commands_test.go
@@ -0,0 +1,163 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetOrgID(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
+ }))
+ defer svr.Close()
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ t.Run("returns the original org-id passed if it is set", func(t *testing.T) {
+ expected := "1234"
+ actual, err := compiler.getOrgID("1234", "")
+ assert.NoError(t, err)
+ assert.Equal(t, expected, actual)
+ })
+
+ t.Run("returns the correct org id from org-slug", func(t *testing.T) {
+ expected := "2345"
+ actual, err := compiler.getOrgID("", "gh/test")
+ assert.NoError(t, err)
+ assert.Equal(t, expected, actual)
+ })
+
+ t.Run("returns the correct org id with org-id and org-slug both set", func(t *testing.T) {
+ expected := "1234"
+ actual, err := compiler.getOrgID("1234", "gh/test")
+ assert.NoError(t, err)
+ assert.Equal(t, expected, actual)
+ })
+
+ t.Run("does not return an error if org-id cannot be found", func(t *testing.T) {
+ expected := ""
+ actual, err := compiler.getOrgID("", "gh/doesntexist")
+ assert.NoError(t, err)
+ assert.Equal(t, expected, actual)
+ })
+
+}
+
+var testYaml = `version: 2.1\n\norbs:\n node: circleci/node@5.0.3\n\njobs:\n datadog-hello-world:\n docker:\n - image: cimg/base:stable\n steps:\n - run: |\n echo \"doing something really cool\"\nworkflows:\n datadog-hello-world:\n jobs:\n - datadog-hello-world\n`
+
+func TestValidateConfig(t *testing.T) {
+ t.Run("validate config works as expected", func(t *testing.T) {
+ t.Run("validate config is able to send a request with no owner-id", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ reqBody, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+
+ var req CompileConfigRequest
+ err = json.Unmarshal(reqBody, &req)
+ assert.NoError(t, err)
+ fmt.Fprintf(w, `{"valid":true,"source-yaml":"%s","output-yaml":"%s","errors":[]}`, testYaml, testYaml)
+ }))
+ defer svr.Close()
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ err := compiler.ValidateConfig(ValidateConfigOpts{
+ ConfigPath: "testdata/config.yml",
+ })
+ assert.NoError(t, err)
+ })
+
+ t.Run("validate config is able to send a request with owner-id", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ reqBody, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+
+ var req CompileConfigRequest
+ err = json.Unmarshal(reqBody, &req)
+ assert.NoError(t, err)
+ assert.Equal(t, "1234", req.Options.OwnerID)
+ fmt.Fprintf(w, `{"valid":true,"source-yaml":"%s","output-yaml":"%s","errors":[]}`, testYaml, testYaml)
+ }))
+ defer svr.Close()
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ err := compiler.ValidateConfig(ValidateConfigOpts{
+ ConfigPath: "testdata/config.yml",
+ OrgID: "1234",
+ })
+ assert.NoError(t, err)
+ })
+
+ t.Run("validate config is able to send a request with owner-id from slug", func(t *testing.T) {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ reqBody, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+
+ var req CompileConfigRequest
+ err = json.Unmarshal(reqBody, &req)
+ assert.NoError(t, err)
+ assert.Equal(t, "2345", req.Options.OwnerID)
+ fmt.Fprintf(w, `{"valid":true,"source-yaml":"%s","output-yaml":"%s","errors":[]}`, testYaml, testYaml)
+ })
+
+ mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
+ })
+
+ svr := httptest.NewServer(mux)
+ defer svr.Close()
+
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ err := compiler.ValidateConfig(ValidateConfigOpts{
+ ConfigPath: "testdata/config.yml",
+ OrgSlug: "gh/test",
+ })
+ assert.NoError(t, err)
+ })
+
+ t.Run("validate config is able to send a request with no owner-id after failed collaborations lookup", func(t *testing.T) {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ reqBody, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+
+ var req CompileConfigRequest
+ err = json.Unmarshal(reqBody, &req)
+ assert.NoError(t, err)
+ assert.Equal(t, "", req.Options.OwnerID)
+ fmt.Fprintf(w, `{"valid":true,"source-yaml":"%s","output-yaml":"%s","errors":[]}`, testYaml, testYaml)
+ })
+
+ mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
+ })
+
+ svr := httptest.NewServer(mux)
+ defer svr.Close()
+
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ err := compiler.ValidateConfig(ValidateConfigOpts{
+ ConfigPath: "testdata/config.yml",
+ OrgSlug: "gh/nonexistent",
+ })
+ assert.NoError(t, err)
+ })
+ })
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 000000000..b8cb5921b
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,150 @@
+package config
+
+import (
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+
+ "github.com/CircleCI-Public/circleci-cli/api/graphql"
+ "github.com/CircleCI-Public/circleci-cli/api/rest"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/pkg/errors"
+)
+
+var (
+ defaultHost = "https://circleci.com"
+ defaultAPIHost = "https://api.circleci.com"
+
+ // Making this the one true source for default config path
+ DefaultConfigPath = ".circleci/config.yml"
+)
+
+type ConfigCompiler struct {
+ host string
+ compileRestClient *rest.Client
+ collaboratorRestClient *rest.Client
+
+ cfg *settings.Config
+ legacyGraphQLClient *graphql.Client
+}
+
+func New(cfg *settings.Config) *ConfigCompiler {
+ hostValue := getCompileHost(cfg.Host)
+ configCompiler := &ConfigCompiler{
+ host: hostValue,
+ compileRestClient: rest.NewFromConfig(hostValue, cfg),
+ collaboratorRestClient: rest.NewFromConfig(cfg.Host, cfg),
+ cfg: cfg,
+ }
+
+ configCompiler.legacyGraphQLClient = graphql.NewClient(cfg.HTTPClient, cfg.Host, cfg.Endpoint, cfg.Token, cfg.Debug)
+ return configCompiler
+}
+
+func getCompileHost(cfgHost string) string {
+ if cfgHost != defaultHost {
+ return cfgHost
+ } else {
+ return defaultAPIHost
+ }
+}
+
+type ConfigError struct {
+ Message string `json:"message"`
+}
+
+// ConfigResponse - the structure of what is returned from the downstream compilation endpoint
+type ConfigResponse struct {
+ Valid bool `json:"valid"`
+ SourceYaml string `json:"source-yaml"`
+ OutputYaml string `json:"output-yaml"`
+ Errors []ConfigError `json:"errors"`
+}
+
+// CompileConfigRequest - the structure of the data we send to the downstream compilation service.
+type CompileConfigRequest struct {
+ ConfigYaml string `json:"config_yaml"`
+ Options Options `json:"options"`
+}
+
+type Options struct {
+ OwnerID string `json:"owner_id,omitempty"`
+ PipelineParameters map[string]interface{} `json:"pipeline_parameters,omitempty"`
+ PipelineValues map[string]interface{} `json:"pipeline_values,omitempty"`
+}
+
+// ConfigQuery - attempts to compile or validate a given config file with the
+// passed in params/values.
+// If the orgID is passed in, the config-compilation with private orbs should work.
+func (c *ConfigCompiler) ConfigQuery(
+ configPath string,
+ orgID string,
+ params Parameters,
+ values Values,
+) (*ConfigResponse, error) {
+ configString, err := loadYaml(configPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load yaml config from config path provider: %w", err)
+ }
+
+ compileRequest := CompileConfigRequest{
+ ConfigYaml: configString,
+ Options: Options{
+ OwnerID: orgID,
+ PipelineValues: values,
+ PipelineParameters: params,
+ },
+ }
+
+ req, err := c.compileRestClient.NewRequest(
+ "POST",
+ &url.URL{
+ Path: "compile-config-with-defaults",
+ },
+ compileRequest,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("an error occurred creating the request: %w", err)
+ }
+
+ configCompilationResp := &ConfigResponse{}
+ statusCode, originalErr := c.compileRestClient.DoRequest(req, configCompilationResp)
+ if statusCode == 404 {
+ fmt.Fprintf(os.Stderr, "You are using a old version of CircleCI Server, please consider updating\n")
+ legacyResponse, err := c.legacyConfigQueryByOrgID(configString, orgID, params, values, c.cfg)
+ if err != nil {
+ return nil, err
+ }
+ return legacyResponse, nil
+ }
+ if originalErr != nil {
+ return nil, fmt.Errorf("config compilation request returned an error: %w", err)
+ }
+
+ if statusCode != 200 {
+ return nil, errors.New("unable to validate or compile config")
+ }
+
+ if len(configCompilationResp.Errors) > 0 {
+ return nil, fmt.Errorf("config compilation contains errors: %s", configCompilationResp.Errors)
+ }
+
+ return configCompilationResp, nil
+}
+
+func loadYaml(path string) (string, error) {
+ var err error
+ var config []byte
+ if path == "-" {
+ config, err = io.ReadAll(os.Stdin)
+ } else {
+ config, err = os.ReadFile(path)
+ }
+
+ if err != nil {
+ return "", errors.Wrapf(err, "Could not load config file at %s", path)
+ }
+
+ return string(config), nil
+}
diff --git a/config/config_test.go b/config/config_test.go
new file mode 100644
index 000000000..036d042d1
--- /dev/null
+++ b/config/config_test.go
@@ -0,0 +1,141 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCompiler(t *testing.T) {
+ t.Run("test compiler setup", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
+ }))
+ defer svr.Close()
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ t.Run("assert compiler has correct host", func(t *testing.T) {
+ assert.Equal(t, "http://"+compiler.compileRestClient.BaseURL.Host, svr.URL)
+ })
+
+ t.Run("assert compiler has default api host", func(t *testing.T) {
+ newCompiler := New(&settings.Config{Host: defaultHost, HTTPClient: http.DefaultClient})
+ assert.Equal(t, "https://"+newCompiler.compileRestClient.BaseURL.Host, defaultAPIHost)
+ })
+
+ t.Run("tests that we correctly get the config api host when the host is not the default one", func(t *testing.T) {
+ // if the host isn't equal to `https://circleci.com` then this is likely a server instance and
+ // wont have the api.X.com subdomain so we should instead just respect the host for config commands
+ host := getCompileHost("test")
+ assert.Equal(t, host, "test")
+
+ // If the host passed in is the same as the defaultHost 'https://circleci.com' - then we know this is cloud
+ // and as such should use the `api.circleci.com` subdomain
+ host = getCompileHost("https://circleci.com")
+ assert.Equal(t, host, "https://api.circleci.com")
+ })
+ })
+
+ t.Run("test ConfigQuery", func(t *testing.T) {
+ t.Run("returns the correct configCompilation response", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"valid":true,"source-yaml":"source","output-yaml":"output","errors":[]}`)
+ }))
+ defer svr.Close()
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ result, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
+ assert.NoError(t, err)
+ assert.Equal(t, true, result.Valid)
+ assert.Equal(t, "output", result.OutputYaml)
+ assert.Equal(t, "source", result.SourceYaml)
+ })
+
+ t.Run("returns error when config file could not be found", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"valid":true,"source-yaml":"source","output-yaml":"output","errors":[]}`)
+ }))
+ defer svr.Close()
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ _, err := compiler.ConfigQuery("testdata/nonexistent.yml", "1234", Parameters{}, Values{})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "Could not load config file at testdata/nonexistent.yml")
+ })
+
+ // commenting this out - we have a legacy_test.go unit test that covers this behaviour
+ // t.Run("handles 404 status correctly", func(t *testing.T) {
+ // svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // w.WriteHeader(http.StatusNotFound)
+ // }))
+ // defer svr.Close()
+ // compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ // _, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
+ // assert.Error(t, err)
+ // assert.Contains(t, err.Error(), "this version of the CLI does not support your instance of server")
+ // })
+
+ t.Run("handles non-200 status correctly", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer svr.Close()
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ _, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "config compilation request returned an error")
+ })
+
+ t.Run("server gets correct information owner ID", func(t *testing.T) {
+ svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ reqBody, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+
+ var req CompileConfigRequest
+ err = json.Unmarshal(reqBody, &req)
+ assert.NoError(t, err)
+ assert.Equal(t, "1234", req.Options.OwnerID)
+ assert.Equal(t, "test: test\n", req.ConfigYaml)
+ fmt.Fprintf(w, `{"valid":true,"source-yaml":"source","output-yaml":"output","errors":[]}`)
+ }))
+ defer svr.Close()
+ compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient})
+
+ resp, err := compiler.ConfigQuery("testdata/test.yml", "1234", Parameters{}, Values{})
+ assert.NoError(t, err)
+ assert.Equal(t, true, resp.Valid)
+ assert.Equal(t, "output", resp.OutputYaml)
+ assert.Equal(t, "source", resp.SourceYaml)
+ })
+
+ })
+
+}
+
+func TestLoadYaml(t *testing.T) {
+ t.Run("tests load yaml", func(t *testing.T) {
+ expected := `test: test
+`
+ actual, err := loadYaml("testdata/test.yml")
+ assert.NoError(t, err)
+ assert.Equal(t, expected, actual)
+ })
+
+ t.Run("returns error for non-existent yml file", func(t *testing.T) {
+ actual, err := loadYaml("testdata/non-existent.yml")
+ assert.Error(t, err)
+ assert.Equal(t, "", actual)
+ })
+}
diff --git a/config/deprecated.go b/config/deprecated.go
new file mode 100644
index 000000000..fc8501ae0
--- /dev/null
+++ b/config/deprecated.go
@@ -0,0 +1,72 @@
+package config
+
+import (
+ "github.com/pkg/errors"
+ "gopkg.in/yaml.v3"
+)
+
+// CircleCI Linux VM images that will be permanently removed on May 31st.
+var deprecatedImages = []string{
+ "circleci/classic:201710-01",
+ "circleci/classic:201703-01",
+ "circleci/classic:201707-01",
+ "circleci/classic:201708-01",
+ "circleci/classic:201709-01",
+ "circleci/classic:201710-02",
+ "circleci/classic:201711-01",
+ "circleci/classic",
+ "circleci/classic:latest",
+ "circleci/classic:edge",
+ "circleci/classic:201808-01",
+ "ubuntu-1604:201903-01",
+ "ubuntu-1604:202004-01",
+ "ubuntu-1604:202007-01",
+ "ubuntu-1604:202010-01",
+ "ubuntu-1604:202101-01",
+ "ubuntu-1604:202104-01",
+}
+
+// Simplified Config -> job Structure for an image
+
+type job struct {
+ Machine interface{} `yaml:"machine"`
+}
+
+// Simplified Config Structure for an image
+type processedConfig struct {
+ Jobs map[string]job `yaml:"jobs"`
+}
+
+// Processes the config down to v2.0, then checks image used against the block list
+func deprecatedImageCheck(response *ConfigResponse) error {
+ aConfig := processedConfig{}
+ err := yaml.Unmarshal([]byte(response.OutputYaml), &aConfig)
+ if err != nil {
+ return err
+ }
+
+ // check each job
+ for key := range aConfig.Jobs {
+
+ switch aConfig.Jobs[key].Machine.(type) {
+ case bool, nil:
+ // using machine true
+ continue
+ }
+
+ image := aConfig.Jobs[key].Machine.(map[string]interface{})["image"]
+
+ // using the `docker`/`xcode` executors
+ if image == nil {
+ continue
+ }
+
+ for _, v := range deprecatedImages {
+ if image.(string) == v {
+ return errors.New("The config is using a deprecated Linux VM image (" + v + "). Please see https://circleci.com/blog/ubuntu-14-16-image-deprecation/. This error can be ignored by using the '--ignore-deprecated-images' flag.")
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/config/deprecated_test.go b/config/deprecated_test.go
new file mode 100644
index 000000000..8f7c53c00
--- /dev/null
+++ b/config/deprecated_test.go
@@ -0,0 +1,35 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+var TestErrorCase = `
+jobs:
+ job:
+ machine: circleci/classic:201710-01
+`
+
+var TestHappyCase = `
+jobs:
+ job:
+ image: non/deprecated
+`
+
+func TestDeprecatedImageCheck(t *testing.T) {
+ t.Run("happy path - tests deprecated image check works", func(t *testing.T) {
+ err := deprecatedImageCheck(&ConfigResponse{
+ OutputYaml: TestErrorCase,
+ })
+ assert.Error(t, err)
+ })
+
+ t.Run("happy path - no error if image used", func(t *testing.T) {
+ err := deprecatedImageCheck(&ConfigResponse{
+ OutputYaml: TestHappyCase,
+ })
+ assert.Nil(t, err)
+ })
+}
diff --git a/config/legacy.go b/config/legacy.go
new file mode 100644
index 000000000..02581bb74
--- /dev/null
+++ b/config/legacy.go
@@ -0,0 +1,141 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/CircleCI-Public/circleci-cli/api/graphql"
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/pkg/errors"
+)
+
+// GQLErrorsCollection is a slice of errors returned by the GraphQL server.
+// Each error is made up of a GQLResponseError type.
+type GQLErrorsCollection []GQLResponseError
+
+// BuildConfigResponse wraps the GQL result of the ConfigQuery
+type BuildConfigResponse struct {
+ BuildConfig struct {
+ LegacyConfigResponse
+ }
+}
+
+// Error turns a GQLErrorsCollection into an acceptable error string that can be printed to the user.
+func (errs GQLErrorsCollection) Error() string {
+ messages := []string{}
+
+ for i := range errs {
+ messages = append(messages, errs[i].Message)
+ }
+
+ return strings.Join(messages, "\n")
+}
+
+// LegacyConfigResponse is a structure that matches the result of the GQL
+// query, so that we can use mapstructure to convert from
+// nested maps to a strongly typed struct.
+type LegacyConfigResponse struct {
+ Valid bool
+ SourceYaml string
+ OutputYaml string
+
+ Errors GQLErrorsCollection
+}
+
+// GQLResponseError is a mapping of the data returned by the GraphQL server of key-value pairs.
+// Typically used with the structure "Message: string", but other response errors provide additional fields.
+type GQLResponseError struct {
+ Message string
+ Value string
+ AllowedValues []string
+ EnumType string
+ Type string
+}
+
+// PrepareForGraphQL takes a golang homogenous map, and transforms it into a list of keyval pairs, since GraphQL does not support homogenous maps.
+func PrepareForGraphQL(kvMap Values) []KeyVal {
+ // we need to create the slice of KeyVals in a deterministic order for testing purposes
+ keys := make([]string, 0, len(kvMap))
+ for k := range kvMap {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ kvs := make([]KeyVal, 0, len(kvMap))
+ for _, k := range keys {
+ kvs = append(kvs, KeyVal{Key: k, Val: kvMap[k]})
+ }
+ return kvs
+}
+
+func (c *ConfigCompiler) legacyConfigQueryByOrgID(
+ configString string,
+ orgID string,
+ params Parameters,
+ values Values,
+ cfg *settings.Config,
+) (*ConfigResponse, error) {
+ var response BuildConfigResponse
+ // GraphQL isn't forwards-compatible, so we are unusually selective here about
+ // passing only non-empty fields on to the API, to minimize user impact if the
+ // backend is out of date.
+ var fieldAddendums string
+ if orgID != "" {
+ fieldAddendums += ", orgId: $orgId"
+ }
+ if len(params) > 0 {
+ fieldAddendums += ", pipelineParametersJson: $pipelineParametersJson"
+ }
+ query := fmt.Sprintf(
+ `query ValidateConfig ($config: String!, $pipelineParametersJson: String, $pipelineValues: [StringKeyVal!], $orgSlug: String) {
+ buildConfig(configYaml: $config, pipelineValues: $pipelineValues%s) {
+ valid,
+ errors { message },
+ sourceYaml,
+ outputYaml
+ }
+ }`,
+ fieldAddendums,
+ )
+
+ request := graphql.NewRequest(query)
+ request.SetToken(cfg.Token)
+ request.Var("config", configString)
+
+ if values != nil {
+ request.Var("pipelineValues", PrepareForGraphQL(values))
+ }
+ if params != nil {
+ pipelineParameters, err := json.Marshal(params)
+ if err != nil {
+ return nil, fmt.Errorf("unable to serialize pipeline values: %s", err.Error())
+ }
+ request.Var("pipelineParametersJson", string(pipelineParameters))
+ }
+
+ if orgID != "" {
+ request.Var("orgId", orgID)
+ }
+
+ err := c.legacyGraphQLClient.Run(request, &response)
+ if err != nil {
+ return nil, errors.Wrap(err, "Unable to validate config")
+ }
+ if len(response.BuildConfig.LegacyConfigResponse.Errors) > 0 {
+ return nil, &response.BuildConfig.LegacyConfigResponse.Errors
+ }
+
+ return &ConfigResponse{
+ Valid: response.BuildConfig.LegacyConfigResponse.Valid,
+ SourceYaml: response.BuildConfig.LegacyConfigResponse.SourceYaml,
+ OutputYaml: response.BuildConfig.LegacyConfigResponse.OutputYaml,
+ }, nil
+}
+
+// KeyVal is a data structure specifically for passing pipeline data to GraphQL which doesn't support free-form maps.
+type KeyVal struct {
+ Key string `json:"key"`
+ Val interface{} `json:"val"`
+}
diff --git a/config/legacy_test.go b/config/legacy_test.go
new file mode 100644
index 000000000..5175a136f
--- /dev/null
+++ b/config/legacy_test.go
@@ -0,0 +1,111 @@
+package config
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/CircleCI-Public/circleci-cli/settings"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLegacyFlow(t *testing.T) {
+ t.Run("tests that the compiler defaults to the graphQL resolver should the original API request fail with 404", func(t *testing.T) {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ })
+
+ mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
+ })
+
+ mux.HandleFunc("/graphql-unstable", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"data":{"buildConfig": {"valid":true,"sourceYaml":"%s","outputYaml":"%s","errors":[]}}}`, testYaml, testYaml)
+ })
+
+ svr := httptest.NewServer(mux)
+ defer svr.Close()
+
+ compiler := New(&settings.Config{
+ Host: svr.URL,
+ Endpoint: "/graphql-unstable",
+ HTTPClient: http.DefaultClient,
+ Token: "",
+ })
+ resp, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
+
+ assert.Equal(t, true, resp.Valid)
+ assert.NoError(t, err)
+ })
+
+ t.Run("tests that the compiler handles errors properly when returned from the graphQL endpoint", func(t *testing.T) {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ })
+
+ mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
+ })
+
+ mux.HandleFunc("/graphql-unstable", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"data":{"buildConfig":{"errors":[{"message": "failed to validate"}]}}}`)
+ })
+
+ svr := httptest.NewServer(mux)
+ defer svr.Close()
+
+ compiler := New(&settings.Config{
+ Host: svr.URL,
+ Endpoint: "/graphql-unstable",
+ HTTPClient: http.DefaultClient,
+ Token: "",
+ })
+ _, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to validate")
+ })
+
+ t.Run("tests that the compiler fails out completely when a non-404 is returned from the http endpoint", func(t *testing.T) {
+ mux := http.NewServeMux()
+ gqlHitCounter := 0
+
+ mux.HandleFunc("/compile-config-with-defaults", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+
+ })
+
+ mux.HandleFunc("/me/collaborations", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`)
+ })
+
+ mux.HandleFunc("/graphql-unstable", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"data":{"buildConfig":{"errors":[{"message": "failed to validate"}]}}}`)
+ gqlHitCounter++
+ })
+
+ svr := httptest.NewServer(mux)
+ defer svr.Close()
+
+ compiler := New(&settings.Config{
+ Host: svr.URL,
+ Endpoint: "/graphql-unstable",
+ HTTPClient: http.DefaultClient,
+ Token: "",
+ })
+ _, err := compiler.ConfigQuery("testdata/config.yml", "1234", Parameters{}, Values{})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "config compilation request returned an error:")
+ assert.Equal(t, 0, gqlHitCounter)
+ })
+}
diff --git a/pipeline/pipeline.go b/config/pipeline.go
similarity index 61%
rename from pipeline/pipeline.go
rename to config/pipeline.go
index 7daefa96b..923630c4e 100644
--- a/pipeline/pipeline.go
+++ b/config/pipeline.go
@@ -1,14 +1,13 @@
-package pipeline
+package config
import (
"fmt"
- "sort"
"github.com/CircleCI-Public/circleci-cli/git"
)
// CircleCI provides various `<< pipeline.x >>` values to be used in your config, but sometimes we need to fabricate those values when validating config.
-type Values map[string]string
+type Values map[string]interface{}
// Static typing is bypassed using an empty interface here due to pipeline parameters supporting multiple types.
type Parameters map[string]interface{}
@@ -32,9 +31,9 @@ func LocalPipelineValues() Values {
}
}
- vals := map[string]string{
+ vals := map[string]interface{}{
"id": "00000000-0000-0000-0000-000000000001",
- "number": "1",
+ "number": 1,
"project.git_url": gitUrl,
"project.type": projectType,
"git.tag": git.Tag(),
@@ -45,27 +44,3 @@ func LocalPipelineValues() Values {
return vals
}
-
-// TODO: type Parameters map[string]string
-
-// KeyVal is a data structure specifically for passing pipeline data to GraphQL which doesn't support free-form maps.
-type KeyVal struct {
- Key string `json:"key"`
- Val string `json:"val"`
-}
-
-// PrepareForGraphQL takes a golang homogenous map, and transforms it into a list of keyval pairs, since GraphQL does not support homogenous maps.
-func PrepareForGraphQL(kvMap Values) []KeyVal {
- // we need to create the slice of KeyVals in a deterministic order for testing purposes
- keys := make([]string, 0, len(kvMap))
- for k := range kvMap {
- keys = append(keys, k)
- }
- sort.Strings(keys)
-
- kvs := make([]KeyVal, 0, len(kvMap))
- for _, k := range keys {
- kvs = append(kvs, KeyVal{Key: k, Val: kvMap[k]})
- }
- return kvs
-}
diff --git a/config/testdata/config-no-orb.yml b/config/testdata/config-no-orb.yml
new file mode 100644
index 000000000..35f2573f2
--- /dev/null
+++ b/config/testdata/config-no-orb.yml
@@ -0,0 +1,13 @@
+version: 2.1
+
+jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
diff --git a/config/testdata/config.yml b/config/testdata/config.yml
new file mode 100644
index 000000000..d5f89b865
--- /dev/null
+++ b/config/testdata/config.yml
@@ -0,0 +1,16 @@
+version: 2.1
+
+orbs:
+ node: circleci/node@5.0.3
+
+jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
diff --git a/config/testdata/test.yml b/config/testdata/test.yml
new file mode 100644
index 000000000..e5239010e
--- /dev/null
+++ b/config/testdata/test.yml
@@ -0,0 +1 @@
+test: test
diff --git a/data/data.go b/data/data.go
index c0bab327d..9b957e091 100644
--- a/data/data.go
+++ b/data/data.go
@@ -1,39 +1,21 @@
package data
-import (
- packr "github.com/gobuffalo/packr/v2"
- "gopkg.in/yaml.v3"
-)
-
-// YML maps the YAML found in _data/data.yml
-// Be sure to update this type when you modify the structure of that file!
-type YML struct {
+type DataBag struct {
Links struct {
- CLIDocs string `yaml:"cli_docs"`
- OrbDocs string `yaml:"orb_docs"`
- NewAPIToken string `yaml:"new_api_token"`
- } `yaml:"links"`
-}
-
-// LoadData should be called once to decode the YAML into YML.
-func LoadData() (*YML, error) {
- var (
- bts []byte
- err error
- )
-
- d := &YML{}
- box := packr.New("circleci-cli-box", "../_data")
-
- bts, err = box.Find("data.yml")
- if err != nil {
- return nil, err
- }
-
- err = yaml.Unmarshal(bts, &d)
- if err != nil {
- return nil, err
+ CLIDocs string
+ OrbDocs string
+ NewAPIToken string
}
+}
- return d, nil
+var Data = DataBag{
+ Links: struct {
+ CLIDocs string
+ OrbDocs string
+ NewAPIToken string
+ }{
+ CLIDocs: "https://circleci.com/docs/2.0/local-cli/",
+ OrbDocs: "https://circleci.com/docs/2.0/orb-intro/",
+ NewAPIToken: "https://circleci.com/account/api",
+ },
}
diff --git a/filetree/filetree.go b/filetree/filetree.go
index f8c64e110..9de10d4d5 100644
--- a/filetree/filetree.go
+++ b/filetree/filetree.go
@@ -2,7 +2,6 @@ package filetree
import (
"fmt"
- "io/ioutil"
"os"
"path/filepath"
"regexp"
@@ -117,7 +116,7 @@ func (n Node) marshalLeaf() (interface{}, error) {
return content, nil
}
- buf, err := ioutil.ReadFile(n.FullPath)
+ buf, err := os.ReadFile(n.FullPath)
if err != nil {
return content, err
diff --git a/filetree/filetree_test.go b/filetree/filetree_test.go
index 3bf258415..56847f6ff 100644
--- a/filetree/filetree_test.go
+++ b/filetree/filetree_test.go
@@ -1,7 +1,6 @@
package filetree_test
import (
- "io/ioutil"
"os"
"path/filepath"
"sort"
@@ -22,7 +21,7 @@ var _ = Describe("filetree", func() {
BeforeEach(func() {
var err error
- tempRoot, err = ioutil.TempDir("", "circleci-cli-test-")
+ tempRoot, err = os.MkdirTemp("", "circleci-cli-test-")
Expect(err).ToNot(HaveOccurred())
})
@@ -39,7 +38,7 @@ var _ = Describe("filetree", func() {
emptyDir = filepath.Join(tempRoot, "empty_dir")
Expect(os.Mkdir(subDir, 0700)).To(Succeed())
- Expect(ioutil.WriteFile(subDirFile, []byte("foo:\n bar:\n baz"), 0600)).To(Succeed())
+ Expect(os.WriteFile(subDirFile, []byte("foo:\n bar:\n baz"), 0600)).To(Succeed())
Expect(os.Mkdir(emptyDir, 0700)).To(Succeed())
})
@@ -48,7 +47,7 @@ var _ = Describe("filetree", func() {
anotherDir := filepath.Join(tempRoot, "another_dir")
anotherDirFile := filepath.Join(tempRoot, "another_dir", "another_dir_file.yml")
Expect(os.Mkdir(anotherDir, 0700)).To(Succeed())
- Expect(ioutil.WriteFile(anotherDirFile, []byte("1some: in: valid: yaml"), 0600)).To(Succeed())
+ Expect(os.WriteFile(anotherDirFile, []byte("1some: in: valid: yaml"), 0600)).To(Succeed())
tree, err := filetree.NewTree(tempRoot)
Expect(err).ToNot(HaveOccurred())
@@ -118,7 +117,7 @@ sub_dir:
emptyDir = filepath.Join(tempRoot, "empty_dir")
Expect(os.Mkdir(subDir, 0700)).To(Succeed())
- Expect(ioutil.WriteFile(subDirFile, []byte("foo:\n bar:\n baz"), 0600)).To(Succeed())
+ Expect(os.WriteFile(subDirFile, []byte("foo:\n bar:\n baz"), 0600)).To(Succeed())
Expect(os.Mkdir(emptyDir, 0700)).To(Succeed())
})
diff --git a/git/git.go b/git/git.go
index c5802e7b7..23c67453f 100644
--- a/git/git.go
+++ b/git/git.go
@@ -119,7 +119,7 @@ func Branch() string {
// `git rev-parse` works in all versions.
return commandOutputOrDefault(
exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD"),
- "master")
+ "main")
}
func Revision() string {
diff --git a/go.mod b/go.mod
index f7be1eefd..b20bd23c5 100644
--- a/go.mod
+++ b/go.mod
@@ -2,83 +2,99 @@ module github.com/CircleCI-Public/circleci-cli
require (
github.com/AlecAivazis/survey/v2 v2.1.1
+ github.com/CircleCI-Public/circle-policy-agent v0.0.608
github.com/Masterminds/semver v1.4.2
+ github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/blang/semver v3.5.1+incompatible
- github.com/briandowns/spinner v0.0.0-20181018151057-dd69c579ff20
- github.com/fatih/color v1.9.0 // indirect
+ github.com/briandowns/spinner v1.18.1
+ github.com/fatih/color v1.13.0
github.com/go-git/go-git/v5 v5.1.0
- github.com/gobuffalo/buffalo-plugins v1.9.3 // indirect
- github.com/gobuffalo/flect v0.0.0-20181210151238-24a2b68e0316 // indirect
- github.com/gobuffalo/packr/v2 v2.0.0-rc.13
github.com/google/go-github v15.0.0+incompatible // indirect
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 // indirect
+ github.com/google/uuid v1.3.0
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
- github.com/mattn/go-isatty v0.0.12 // indirect
- github.com/mitchellh/mapstructure v1.1.2
- github.com/olekukonko/tablewriter v0.0.4
- github.com/onsi/ginkgo v1.12.1
- github.com/onsi/gomega v1.10.4
+ github.com/mattn/go-isatty v0.0.17 // indirect
+ github.com/mitchellh/mapstructure v1.4.1
+ github.com/olekukonko/tablewriter v0.0.5
+ github.com/onsi/ginkgo v1.16.4
+ github.com/onsi/gomega v1.17.0
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4
- github.com/pkg/errors v0.8.1
+ github.com/pkg/errors v0.9.1
github.com/rhysd/go-github-selfupdate v0.0.0-20180520142321-41c1bbb0804a
- github.com/spf13/cobra v0.0.5
+ github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/ulikunitz/xz v0.5.9 // indirect
- golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc // indirect
+ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
google.golang.org/appengine v1.6.7 // indirect
- gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c
- gotest.tools/v3 v3.0.2
+ gopkg.in/yaml.v3 v3.0.1
+ gotest.tools/v3 v3.0.3
)
require (
- github.com/BurntSushi/toml v0.3.1 // indirect
+ github.com/charmbracelet/lipgloss v0.5.0
+ github.com/erikgeiser/promptkit v0.7.0
+ github.com/hexops/gotextdiff v1.0.3
+ github.com/stretchr/testify v1.8.2
+ golang.org/x/exp v0.0.0-20230321023759-10a507213a29
+)
+
+require (
+ github.com/OneOfOne/xxhash v1.2.8 // indirect
+ github.com/a8m/envsubst v1.4.2 // indirect
+ github.com/agnivade/levenshtein v1.1.1 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/charmbracelet/bubbles v0.11.0 // indirect
+ github.com/charmbracelet/bubbletea v0.21.0 // indirect
+ github.com/containerd/console v1.0.3 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
- github.com/fsnotify/fsnotify v1.4.7 // indirect
+ github.com/fsnotify/fsnotify v1.6.0 // indirect
+ github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/go-billy/v5 v5.0.0 // indirect
- github.com/gobuffalo/envy v1.6.11 // indirect
- github.com/gobuffalo/events v1.1.8 // indirect
- github.com/gobuffalo/genny v0.0.0-20181211165820-e26c8466f14d // indirect
- github.com/gobuffalo/logger v0.0.0-20181127160119-5b956e21995c // indirect
- github.com/gobuffalo/mapi v1.0.1 // indirect
- github.com/gobuffalo/meta v0.0.0-20181127070345-0d7e59dd540b // indirect
- github.com/gobuffalo/packd v0.0.0-20181212173646-eca3b8fd6687 // indirect
- github.com/gobuffalo/syncx v0.0.0-20181120194010-558ac7de985f // indirect
- github.com/golang/protobuf v1.4.2 // indirect
- github.com/google/go-cmp v0.4.0 // indirect
- github.com/imdario/mergo v0.3.9 // indirect
- github.com/inconshreveable/mousetrap v1.0.0 // indirect
+ github.com/gobwas/glob v0.2.3 // indirect
+ github.com/golang/protobuf v1.5.3 // indirect
+ github.com/google/go-cmp v0.5.9 // indirect
+ github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect
+ github.com/imdario/mergo v0.3.12 // indirect
+ github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
- github.com/joho/godotenv v1.3.0 // indirect
- github.com/karrick/godirwalk v1.7.7 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
- github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
- github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2 // indirect
- github.com/markbates/safe v1.0.1 // indirect
- github.com/mattn/go-colorable v0.1.4 // indirect
- github.com/mattn/go-runewidth v0.0.7 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
- github.com/nxadm/tail v1.4.4 // indirect
- github.com/rogpeppe/go-internal v1.0.0 // indirect
+ github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
+ github.com/muesli/cancelreader v0.2.0 // indirect
+ github.com/muesli/reflow v0.3.0 // indirect
+ github.com/muesli/termenv v0.12.0 // indirect
+ github.com/nxadm/tail v1.4.8 // indirect
+ github.com/open-policy-agent/opa v0.50.2 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
- github.com/sirupsen/logrus v1.2.0 // indirect
+ github.com/tchap/go-patricia/v2 v2.3.1 // indirect
github.com/xanzy/ssh-agent v0.2.1 // indirect
- golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
- golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb // indirect
- golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f // indirect
- golang.org/x/text v0.3.3 // indirect
- golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 // indirect
- golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
- google.golang.org/protobuf v1.23.0 // indirect
+ github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
+ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
+ github.com/yashtewari/glob-intersection v0.1.0 // indirect
+ golang.org/x/crypto v0.3.0 // indirect
+ golang.org/x/net v0.8.0 // indirect
+ golang.org/x/sys v0.6.0 // indirect
+ golang.org/x/term v0.6.0 // indirect
+ golang.org/x/text v0.8.0 // indirect
+ google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
- gopkg.in/yaml.v2 v2.3.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
)
// fix vulnerability: CVE-2020-15114 in etcd v3.3.10+incompatible
replace github.com/coreos/etcd => github.com/coreos/etcd v3.3.24+incompatible
-go 1.17
+go 1.20
diff --git a/go.sum b/go.sum
index 7af4f8541..3b83fe785 100644
--- a/go.sum
+++ b/go.sum
@@ -1,47 +1,117 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
+cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
+cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
+cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
+cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
+cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
+cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
+cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
+cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
+cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AlecAivazis/survey/v2 v2.1.1 h1:LEMbHE0pLj75faaVEKClEX1TM4AJmmnOh9eimREzLWI=
github.com/AlecAivazis/survey/v2 v2.1.1/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk=
-github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/CircleCI-Public/circle-policy-agent v0.0.608 h1:3Bhimsdrhwiz2J7ssx90vVH5o8dviZb5+pWUzuMg9E0=
+github.com/CircleCI-Public/circle-policy-agent v0.0.608/go.mod h1:EJlgvMigkiPmhzRJrnqzucVtIYB0TlbNzaSAB2gEL9Q=
github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc=
github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
-github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
+github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
+github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
+github.com/a8m/envsubst v1.4.2 h1:4yWIHXOLEJHQEFd4UjrWDrYeYlV7ncFWJOCBRLOZHQg=
+github.com/a8m/envsubst v1.4.2/go.mod h1:MVUTQNGQ3tsjOOtKCNd+fl8RzhsXcDvvAEzkhGtlsbY=
+github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
+github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
+github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
+github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
+github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
-github.com/briandowns/spinner v0.0.0-20181018151057-dd69c579ff20 h1:kWWOFAhyzkpi4/+L3++mYiZbuxh1TqYkDMHfFjk6ZfE=
-github.com/briandowns/spinner v0.0.0-20181018151057-dd69c579ff20/go.mod h1:hw/JEQBIE+c/BLI4aKM8UU8v+ZqrD3h7HC27kKt8JQU=
-github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
-github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk=
-github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0=
-github.com/coreos/etcd v3.3.24+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
+github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
+github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
+github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q=
+github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
+github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
+github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
+github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
+github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dustin/go-humanize v0.0.0-20180713052910-9f541cc9db5d/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
+github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
+github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
+github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
+github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/erikgeiser/promptkit v0.7.0 h1:Yi28iN6JRs8/0x+wjQRPfWb+vWz1pFmZ5fu2uoFipD8=
+github.com/erikgeiser/promptkit v0.7.0/go.mod h1:Jj9bhN+N8RbMjB1jthkr9A4ydmczZ1WZJ8xTXnP12dg=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
-github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
-github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
-github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
+github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
+github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
+github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
+github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
@@ -52,488 +122,538 @@ github.com/go-git/go-git-fixtures/v4 v4.0.1 h1:q+IFMfLx200Q3scvt2hN79JsEzy4AmBTp
github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw=
github.com/go-git/go-git/v5 v5.1.0 h1:HxJn9g/E7eYvKW3Fm7Jt4ee8LXfPOm/H1cdDu8vEssk=
github.com/go-git/go-git/v5 v5.1.0/go.mod h1:ZKfuPUoY1ZqIG4QG9BDBh3G4gLM5zvPuSJAozQrZuyM=
-github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/gobuffalo/buffalo v0.12.8-0.20181004233540-fac9bb505aa8/go.mod h1:sLyT7/dceRXJUxSsE813JTQtA3Eb1vjxWfo/N//vXIY=
-github.com/gobuffalo/buffalo v0.13.0/go.mod h1:Mjn1Ba9wpIbpbrD+lIDMy99pQ0H0LiddMIIDGse7qT4=
-github.com/gobuffalo/buffalo-plugins v1.0.2/go.mod h1:pOp/uF7X3IShFHyobahTkTLZaeUXwb0GrUTb9ngJWTs=
-github.com/gobuffalo/buffalo-plugins v1.0.4/go.mod h1:pWS1vjtQ6uD17MVFWf7i3zfThrEKWlI5+PYLw/NaDB4=
-github.com/gobuffalo/buffalo-plugins v1.4.3/go.mod h1:uCzTY0woez4nDMdQjkcOYKanngeUVRO2HZi7ezmAjWY=
-github.com/gobuffalo/buffalo-plugins v1.5.1/go.mod h1:jbmwSZK5+PiAP9cC09VQOrGMZFCa/P0UMlIS3O12r5w=
-github.com/gobuffalo/buffalo-plugins v1.6.4/go.mod h1:/+N1aophkA2jZ1ifB2O3Y9yGwu6gKOVMtUmJnbg+OZI=
-github.com/gobuffalo/buffalo-plugins v1.6.5/go.mod h1:0HVkbgrVs/MnPZ/FOseDMVanCTm2RNcdM0PuXcL1NNI=
-github.com/gobuffalo/buffalo-plugins v1.6.7/go.mod h1:ZGZRkzz2PiKWHs0z7QsPBOTo2EpcGRArMEym6ghKYgk=
-github.com/gobuffalo/buffalo-plugins v1.6.9/go.mod h1:yYlYTrPdMCz+6/+UaXg5Jm4gN3xhsvsQ2ygVatZV5vw=
-github.com/gobuffalo/buffalo-plugins v1.6.11/go.mod h1:eAA6xJIL8OuynJZ8amXjRmHND6YiusVAaJdHDN1Lu8Q=
-github.com/gobuffalo/buffalo-plugins v1.8.2/go.mod h1:9te6/VjEQ7pKp7lXlDIMqzxgGpjlKoAcAANdCgoR960=
-github.com/gobuffalo/buffalo-plugins v1.8.3/go.mod h1:IAWq6vjZJVXebIq2qGTLOdlXzmpyTZ5iJG5b59fza5U=
-github.com/gobuffalo/buffalo-plugins v1.9.3 h1:VD4RWD4NjN+FgAFpSpxH9Lmr/XJnsKm1ebONfBzRRU8=
-github.com/gobuffalo/buffalo-plugins v1.9.3/go.mod h1:BNRunDThMZKjqx6R+n14Rk3sRSOWgbMuzCKXLqbd7m0=
-github.com/gobuffalo/buffalo-pop v1.0.5/go.mod h1:Fw/LfFDnSmB/vvQXPvcXEjzP98Tc+AudyNWUBWKCwQ8=
-github.com/gobuffalo/envy v1.6.4/go.mod h1:Abh+Jfw475/NWtYMEt+hnJWRiC8INKWibIMyNt1w2Mc=
-github.com/gobuffalo/envy v1.6.5/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ=
-github.com/gobuffalo/envy v1.6.6/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ=
-github.com/gobuffalo/envy v1.6.7/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ=
-github.com/gobuffalo/envy v1.6.8/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ=
-github.com/gobuffalo/envy v1.6.9/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ=
-github.com/gobuffalo/envy v1.6.10/go.mod h1:X0CFllQjTV5ogsnUrg+Oks2yTI+PU2dGYBJOEI2D1Uo=
-github.com/gobuffalo/envy v1.6.11 h1:dCKSFypLRvqaaUtyzkfKKF2j35ce5agsqfyIrRmm02E=
-github.com/gobuffalo/envy v1.6.11/go.mod h1:Fiq52W7nrHGDggFPhn2ZCcHw4u/rqXkqo+i7FB6EAcg=
-github.com/gobuffalo/events v1.0.3/go.mod h1:Txo8WmqScapa7zimEQIwgiJBvMECMe9gJjsKNPN3uZw=
-github.com/gobuffalo/events v1.0.7/go.mod h1:z8txf6H9jWhQ5Scr7YPLWg/cgXBRj8Q4uYI+rsVCCSQ=
-github.com/gobuffalo/events v1.0.8/go.mod h1:A5KyqT1sA+3GJiBE4QKZibse9mtOcI9nw8gGrDdqYGs=
-github.com/gobuffalo/events v1.1.3/go.mod h1:9yPGWYv11GENtzrIRApwQRMYSbUgCsZ1w6R503fCfrk=
-github.com/gobuffalo/events v1.1.4/go.mod h1:09/YRRgZHEOts5Isov+g9X2xajxdvOAcUuAHIX/O//A=
-github.com/gobuffalo/events v1.1.5/go.mod h1:3YUSzgHfYctSjEjLCWbkXP6djH2M+MLaVRzb4ymbAK0=
-github.com/gobuffalo/events v1.1.7/go.mod h1:6fGqxH2ing5XMb3EYRq9LEkVlyPGs4oO/eLzh+S8CxY=
-github.com/gobuffalo/events v1.1.8 h1:T9SXUVyO1kF61uns5a8cpqsSPj5txZgIplbOoNNltqk=
-github.com/gobuffalo/events v1.1.8/go.mod h1:UFy+W6X6VbCWS8k2iT81HYX65dMtiuVycMy04cplt/8=
-github.com/gobuffalo/fizz v1.0.12/go.mod h1:C0sltPxpYK8Ftvf64kbsQa2yiCZY4RZviurNxXdAKwc=
-github.com/gobuffalo/flect v0.0.0-20180907193754-dc14d8acaf9f/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA=
-github.com/gobuffalo/flect v0.0.0-20181002182613-4571df4b1daf/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA=
-github.com/gobuffalo/flect v0.0.0-20181007231023-ae7ed6bfe683/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA=
-github.com/gobuffalo/flect v0.0.0-20181018182602-fd24a256709f/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA=
-github.com/gobuffalo/flect v0.0.0-20181019110701-3d6f0b585514/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA=
-github.com/gobuffalo/flect v0.0.0-20181024204909-8f6be1a8c6c2/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA=
-github.com/gobuffalo/flect v0.0.0-20181104133451-1f6e9779237a/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA=
-github.com/gobuffalo/flect v0.0.0-20181114183036-47375f6d8328/go.mod h1:0HvNbHdfh+WOvDSIASqJOSxTOWSxCCUF++k/Y53v9rI=
-github.com/gobuffalo/flect v0.0.0-20181210151238-24a2b68e0316 h1:yXFEtYWu8O6g1GwaSGB7adox0EB7jYSp7F8nqKcpUzw=
-github.com/gobuffalo/flect v0.0.0-20181210151238-24a2b68e0316/go.mod h1:en58vff74S9b99Eg42Dr+/9yPu437QjlNsO/hBYPuOk=
-github.com/gobuffalo/genny v0.0.0-20180924032338-7af3a40f2252/go.mod h1:tUTQOogrr7tAQnhajMSH6rv1BVev34H2sa1xNHMy94g=
-github.com/gobuffalo/genny v0.0.0-20181003150629-3786a0744c5d/go.mod h1:WAd8HmjMVrnkAZbmfgH5dLBUchsZfqzp/WS5sQz+uTM=
-github.com/gobuffalo/genny v0.0.0-20181005145118-318a41a134cc/go.mod h1:WAd8HmjMVrnkAZbmfgH5dLBUchsZfqzp/WS5sQz+uTM=
-github.com/gobuffalo/genny v0.0.0-20181007153042-b8de7d566757/go.mod h1:+oG5Ljrw04czAHbPXREwaFojJbpUvcIy4DiOnbEJFTA=
-github.com/gobuffalo/genny v0.0.0-20181012161047-33e5f43d83a6/go.mod h1:+oG5Ljrw04czAHbPXREwaFojJbpUvcIy4DiOnbEJFTA=
-github.com/gobuffalo/genny v0.0.0-20181017160347-90a774534246/go.mod h1:+oG5Ljrw04czAHbPXREwaFojJbpUvcIy4DiOnbEJFTA=
-github.com/gobuffalo/genny v0.0.0-20181024195656-51392254bf53/go.mod h1:o9GEH5gn5sCKLVB5rHFC4tq40rQ3VRUzmx6WwmaqISE=
-github.com/gobuffalo/genny v0.0.0-20181025145300-af3f81d526b8/go.mod h1:uZ1fFYvdcP8mu0B/Ynarf6dsGvp7QFIpk/QACUuFUVI=
-github.com/gobuffalo/genny v0.0.0-20181027191429-94d6cfb5c7fc/go.mod h1:x7SkrQQBx204Y+O9EwRXeszLJDTaWN0GnEasxgLrQTA=
-github.com/gobuffalo/genny v0.0.0-20181027195209-3887b7171c4f/go.mod h1:JbKx8HSWICu5zyqWOa0dVV1pbbXOHusrSzQUprW6g+w=
-github.com/gobuffalo/genny v0.0.0-20181106193839-7dcb0924caf1/go.mod h1:x61yHxvbDCgQ/7cOAbJCacZQuHgB0KMSzoYcw5debjU=
-github.com/gobuffalo/genny v0.0.0-20181107223128-f18346459dbe/go.mod h1:utQD3aKKEsdb03oR+Vi/6ztQb1j7pO10N3OBoowRcSU=
-github.com/gobuffalo/genny v0.0.0-20181114215459-0a4decd77f5d/go.mod h1:kN2KZ8VgXF9VIIOj/GM0Eo7YK+un4Q3tTreKOf0q1ng=
-github.com/gobuffalo/genny v0.0.0-20181119162812-e8ff4adce8bb/go.mod h1:BA9htSe4bZwBDJLe8CUkoqkypq3hn3+CkoHqVOW718E=
-github.com/gobuffalo/genny v0.0.0-20181127225641-2d959acc795b/go.mod h1:l54xLXNkteX/PdZ+HlgPk1qtcrgeOr3XUBBPDbH+7CQ=
-github.com/gobuffalo/genny v0.0.0-20181128191930-77e34f71ba2a/go.mod h1:FW/D9p7cEEOqxYA71/hnrkOWm62JZ5ZNxcNIVJEaWBU=
-github.com/gobuffalo/genny v0.0.0-20181203165245-fda8bcce96b1/go.mod h1:wpNSANu9UErftfiaAlz1pDZclrYzLtO5lALifODyjuM=
-github.com/gobuffalo/genny v0.0.0-20181203201232-849d2c9534ea/go.mod h1:wpNSANu9UErftfiaAlz1pDZclrYzLtO5lALifODyjuM=
-github.com/gobuffalo/genny v0.0.0-20181206121324-d6fb8a0dbe36/go.mod h1:wpNSANu9UErftfiaAlz1pDZclrYzLtO5lALifODyjuM=
-github.com/gobuffalo/genny v0.0.0-20181207164119-84844398a37d/go.mod h1:y0ysCHGGQf2T3vOhCrGHheYN54Y/REj0ayd0Suf4C/8=
-github.com/gobuffalo/genny v0.0.0-20181211165820-e26c8466f14d h1:E9vKkmvd+KhQeOoKvcT4zveTKddiTopk6VifuxuO5LA=
-github.com/gobuffalo/genny v0.0.0-20181211165820-e26c8466f14d/go.mod h1:sHnK+ZSU4e2feXP3PA29ouij6PUEiN+RCwECjCTB3yM=
-github.com/gobuffalo/github_flavored_markdown v1.0.4/go.mod h1:uRowCdK+q8d/RF0Kt3/DSalaIXbb0De/dmTqMQdkQ4I=
-github.com/gobuffalo/github_flavored_markdown v1.0.5/go.mod h1:U0643QShPF+OF2tJvYNiYDLDGDuQmJZXsf/bHOJPsMY=
-github.com/gobuffalo/github_flavored_markdown v1.0.7/go.mod h1:w93Pd9Lz6LvyQXEG6DktTPHkOtCbr+arAD5mkwMzXLI=
-github.com/gobuffalo/httptest v1.0.2/go.mod h1:7T1IbSrg60ankme0aDLVnEY0h056g9M1/ZvpVThtB7E=
-github.com/gobuffalo/licenser v0.0.0-20180924033006-eae28e638a42/go.mod h1:Ubo90Np8gpsSZqNScZZkVXXAo5DGhTb+WYFIjlnog8w=
-github.com/gobuffalo/licenser v0.0.0-20181025145548-437d89de4f75/go.mod h1:x3lEpYxkRG/XtGCUNkio+6RZ/dlOvLzTI9M1auIwFcw=
-github.com/gobuffalo/licenser v0.0.0-20181027200154-58051a75da95/go.mod h1:BzhaaxGd1tq1+OLKObzgdCV9kqVhbTulxOpYbvMQWS0=
-github.com/gobuffalo/licenser v0.0.0-20181109171355-91a2a7aac9a7/go.mod h1:m+Ygox92pi9bdg+gVaycvqE8RVSjZp7mWw75+K5NPHk=
-github.com/gobuffalo/licenser v0.0.0-20181128165715-cc7305f8abed/go.mod h1:oU9F9UCE+AzI/MueCKZamsezGOOHfSirltllOVeRTAE=
-github.com/gobuffalo/licenser v0.0.0-20181203160806-fe900bbede07/go.mod h1:ph6VDNvOzt1CdfaWC+9XwcBnlSTBz2j49PBwum6RFaU=
-github.com/gobuffalo/licenser v0.0.0-20181211173111-f8a311c51159/go.mod h1:ve/Ue99DRuvnTaLq2zKa6F4KtHiYf7W046tDjuGYPfM=
-github.com/gobuffalo/logger v0.0.0-20181022175615-46cfb361fc27/go.mod h1:8sQkgyhWipz1mIctHF4jTxmJh1Vxhp7mP8IqbljgJZo=
-github.com/gobuffalo/logger v0.0.0-20181027144941-73d08d2bb969/go.mod h1:7uGg2duHKpWnN4+YmyKBdLXfhopkAdVM6H3nKbyFbz8=
-github.com/gobuffalo/logger v0.0.0-20181027193913-9cf4dd0efe46/go.mod h1:7uGg2duHKpWnN4+YmyKBdLXfhopkAdVM6H3nKbyFbz8=
-github.com/gobuffalo/logger v0.0.0-20181109185836-3feeab578c17/go.mod h1:oNErH0xLe+utO+OW8ptXMSA5DkiSEDW1u3zGIt8F9Ew=
-github.com/gobuffalo/logger v0.0.0-20181117211126-8e9b89b7c264/go.mod h1:5etB91IE0uBlw9k756fVKZJdS+7M7ejVhmpXXiSFj0I=
-github.com/gobuffalo/logger v0.0.0-20181127160119-5b956e21995c h1:Z/ppYX6EtPEysbW4VEGz2dO+4F4VTthWp2sWRUCANdU=
-github.com/gobuffalo/logger v0.0.0-20181127160119-5b956e21995c/go.mod h1:+HxKANrR9VGw9yN3aOAppJKvhO05ctDi63w4mDnKv2U=
-github.com/gobuffalo/makr v1.1.5/go.mod h1:Y+o0btAH1kYAMDJW/TX3+oAXEu0bmSLLoC9mIFxtzOw=
-github.com/gobuffalo/mapi v1.0.0/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
-github.com/gobuffalo/mapi v1.0.1 h1:JRuTiZzDEZhBHkFiHTxJkYRT6CbYuL0K/rn+1byJoEA=
-github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc=
-github.com/gobuffalo/meta v0.0.0-20181018155829-df62557efcd3/go.mod h1:XTTOhwMNryif3x9LkTTBO/Llrveezd71u3quLd0u7CM=
-github.com/gobuffalo/meta v0.0.0-20181018192820-8c6cef77dab3/go.mod h1:E94EPzx9NERGCY69UWlcj6Hipf2uK/vnfrF4QD0plVE=
-github.com/gobuffalo/meta v0.0.0-20181025145500-3a985a084b0a/go.mod h1:YDAKBud2FP7NZdruCSlmTmDOZbVSa6bpK7LJ/A/nlKg=
-github.com/gobuffalo/meta v0.0.0-20181114191255-b130ebedd2f7/go.mod h1:K6cRZ29ozr4Btvsqkjvg5nDFTLOgTqf03KA70Ks0ypE=
-github.com/gobuffalo/meta v0.0.0-20181127070345-0d7e59dd540b h1:TP4reYa74tCqKmeisA9aULXzcb1fHIM3bi7PdoDxpuQ=
-github.com/gobuffalo/meta v0.0.0-20181127070345-0d7e59dd540b/go.mod h1:RLO7tMvE0IAKAM8wny1aN12pvEKn7EtkBLkUZR00Qf8=
-github.com/gobuffalo/mw-basicauth v1.0.3/go.mod h1:dg7+ilMZOKnQFHDefUzUHufNyTswVUviCBgF244C1+0=
-github.com/gobuffalo/mw-contenttype v0.0.0-20180802152300-74f5a47f4d56/go.mod h1:7EvcmzBbeCvFtQm5GqF9ys6QnCxz2UM1x0moiWLq1No=
-github.com/gobuffalo/mw-csrf v0.0.0-20180802151833-446ff26e108b/go.mod h1:sbGtb8DmDZuDUQoxjr8hG1ZbLtZboD9xsn6p77ppcHo=
-github.com/gobuffalo/mw-forcessl v0.0.0-20180802152810-73921ae7a130/go.mod h1:JvNHRj7bYNAMUr/5XMkZaDcw3jZhUZpsmzhd//FFWmQ=
-github.com/gobuffalo/mw-i18n v0.0.0-20180802152014-e3060b7e13d6/go.mod h1:91AQfukc52A6hdfIfkxzyr+kpVYDodgAeT5cjX1UIj4=
-github.com/gobuffalo/mw-paramlogger v0.0.0-20181005191442-d6ee392ec72e/go.mod h1:6OJr6VwSzgJMqWMj7TYmRUqzNe2LXu/W1rRW4MAz/ME=
-github.com/gobuffalo/mw-tokenauth v0.0.0-20181001105134-8545f626c189/go.mod h1:UqBF00IfKvd39ni5+yI5MLMjAf4gX7cDKN/26zDOD6c=
-github.com/gobuffalo/packd v0.0.0-20181027182251-01ad393492c8/go.mod h1:SmdBdhj6uhOsg1Ui4SFAyrhuc7U4VCildosO5IDJ3lc=
-github.com/gobuffalo/packd v0.0.0-20181027190505-aafc0d02c411/go.mod h1:SmdBdhj6uhOsg1Ui4SFAyrhuc7U4VCildosO5IDJ3lc=
-github.com/gobuffalo/packd v0.0.0-20181027194105-7ae579e6d213/go.mod h1:SmdBdhj6uhOsg1Ui4SFAyrhuc7U4VCildosO5IDJ3lc=
-github.com/gobuffalo/packd v0.0.0-20181031195726-c82734870264/go.mod h1:Yf2toFaISlyQrr5TfO3h6DB9pl9mZRmyvBGQb/aQ/pI=
-github.com/gobuffalo/packd v0.0.0-20181104210303-d376b15f8e96/go.mod h1:Yf2toFaISlyQrr5TfO3h6DB9pl9mZRmyvBGQb/aQ/pI=
-github.com/gobuffalo/packd v0.0.0-20181111195323-b2e760a5f0ff/go.mod h1:Yf2toFaISlyQrr5TfO3h6DB9pl9mZRmyvBGQb/aQ/pI=
-github.com/gobuffalo/packd v0.0.0-20181114190715-f25c5d2471d7/go.mod h1:Yf2toFaISlyQrr5TfO3h6DB9pl9mZRmyvBGQb/aQ/pI=
-github.com/gobuffalo/packd v0.0.0-20181124090624-311c6248e5fb/go.mod h1:Foenia9ZvITEvG05ab6XpiD5EfBHPL8A6hush8SJ0o8=
-github.com/gobuffalo/packd v0.0.0-20181207120301-c49825f8f6f4/go.mod h1:LYc0TGKFBBFTRC9dg2pcRcMqGCTMD7T2BIMP7OBuQAA=
-github.com/gobuffalo/packd v0.0.0-20181212173646-eca3b8fd6687 h1:uZ+G4JprR0UEq0aHZs+6eP7TEZuFfrIkmQWejIBV/QQ=
-github.com/gobuffalo/packd v0.0.0-20181212173646-eca3b8fd6687/go.mod h1:LYc0TGKFBBFTRC9dg2pcRcMqGCTMD7T2BIMP7OBuQAA=
-github.com/gobuffalo/packr v1.13.7/go.mod h1:KkinLIn/n6+3tVXMwg6KkNvWwVsrRAz4ph+jgpk3Z24=
-github.com/gobuffalo/packr v1.15.0/go.mod h1:t5gXzEhIviQwVlNx/+3SfS07GS+cZ2hn76WLzPp6MGI=
-github.com/gobuffalo/packr v1.15.1/go.mod h1:IeqicJ7jm8182yrVmNbM6PR4g79SjN9tZLH8KduZZwE=
-github.com/gobuffalo/packr v1.19.0/go.mod h1:MstrNkfCQhd5o+Ct4IJ0skWlxN8emOq8DsoT1G98VIU=
-github.com/gobuffalo/packr v1.20.0/go.mod h1:JDytk1t2gP+my1ig7iI4NcVaXr886+N0ecUga6884zw=
-github.com/gobuffalo/packr v1.21.0 h1:p2ujcDJQp2QTiYWcI0ByHbr/gMoCouok6M0vXs/yTYQ=
-github.com/gobuffalo/packr v1.21.0/go.mod h1:H00jGfj1qFKxscFJSw8wcL4hpQtPe1PfU2wa6sg/SR0=
-github.com/gobuffalo/packr/v2 v2.0.0-rc.8/go.mod h1:y60QCdzwuMwO2R49fdQhsjCPv7tLQFR0ayzxxla9zes=
-github.com/gobuffalo/packr/v2 v2.0.0-rc.9/go.mod h1:fQqADRfZpEsgkc7c/K7aMew3n4aF1Kji7+lIZeR98Fc=
-github.com/gobuffalo/packr/v2 v2.0.0-rc.10/go.mod h1:4CWWn4I5T3v4c1OsJ55HbHlUEKNWMITG5iIkdr4Px4w=
-github.com/gobuffalo/packr/v2 v2.0.0-rc.11/go.mod h1:JoieH/3h3U4UmatmV93QmqyPUdf4wVM9HELaHEu+3fk=
-github.com/gobuffalo/packr/v2 v2.0.0-rc.12/go.mod h1:FV1zZTsVFi1DSCboO36Xgs4pzCZBjB/tDV9Cz/lSaR8=
-github.com/gobuffalo/packr/v2 v2.0.0-rc.13 h1:24zxY9FohMBT0JOYVjritfdGPVh9uz1YOKHqyDuEEjk=
-github.com/gobuffalo/packr/v2 v2.0.0-rc.13/go.mod h1:2Mp7GhBFMdJlOK8vGfl7SYtfMP3+5roE39ejlfjw0rA=
-github.com/gobuffalo/plush v3.7.16+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI=
-github.com/gobuffalo/plush v3.7.20+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI=
-github.com/gobuffalo/plush v3.7.21+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI=
-github.com/gobuffalo/plush v3.7.22+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI=
-github.com/gobuffalo/plush v3.7.23+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI=
-github.com/gobuffalo/plush v3.7.30+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI=
-github.com/gobuffalo/plush v3.7.31+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI=
-github.com/gobuffalo/plush v3.7.32+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI=
-github.com/gobuffalo/plushgen v0.0.0-20181128164830-d29dcb966cb2/go.mod h1:r9QwptTFnuvSaSRjpSp4S2/4e2D3tJhARYbvEBcKSb4=
-github.com/gobuffalo/plushgen v0.0.0-20181203163832-9fc4964505c2/go.mod h1:opEdT33AA2HdrIwK1aibqnTJDVVKXC02Bar/GT1YRVs=
-github.com/gobuffalo/plushgen v0.0.0-20181207152837-eedb135bd51b/go.mod h1:Lcw7HQbEVm09sAQrCLzIxuhFbB3nAgp4c55E+UlynR0=
-github.com/gobuffalo/pop v4.8.2+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg=
-github.com/gobuffalo/pop v4.8.3+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg=
-github.com/gobuffalo/pop v4.8.4+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg=
-github.com/gobuffalo/release v1.0.35/go.mod h1:VtHFAKs61vO3wboCec5xr9JPTjYyWYcvaM3lclkc4x4=
-github.com/gobuffalo/release v1.0.38/go.mod h1:VtHFAKs61vO3wboCec5xr9JPTjYyWYcvaM3lclkc4x4=
-github.com/gobuffalo/release v1.0.42/go.mod h1:RPs7EtafH4oylgetOJpGP0yCZZUiO4vqHfTHJjSdpug=
-github.com/gobuffalo/release v1.0.52/go.mod h1:RPs7EtafH4oylgetOJpGP0yCZZUiO4vqHfTHJjSdpug=
-github.com/gobuffalo/release v1.0.53/go.mod h1:FdF257nd8rqhNaqtDWFGhxdJ/Ig4J7VcS3KL7n/a+aA=
-github.com/gobuffalo/release v1.0.54/go.mod h1:Pe5/RxRa/BE8whDpGfRqSI7D1a0evGK1T4JDm339tJc=
-github.com/gobuffalo/release v1.0.61/go.mod h1:mfIO38ujUNVDlBziIYqXquYfBF+8FDHUjKZgYC1Hj24=
-github.com/gobuffalo/release v1.0.72/go.mod h1:NP5NXgg/IX3M5XmHmWR99D687/3Dt9qZtTK/Lbwc1hU=
-github.com/gobuffalo/release v1.1.1/go.mod h1:Sluak1Xd6kcp6snkluR1jeXAogdJZpFFRzTYRs/2uwg=
-github.com/gobuffalo/release v1.1.3/go.mod h1:CuXc5/m+4zuq8idoDt1l4va0AXAn/OSs08uHOfMVr8E=
-github.com/gobuffalo/release v1.1.6/go.mod h1:18naWa3kBsqO0cItXZNJuefCKOENpbbUIqRL1g+p6z0=
-github.com/gobuffalo/shoulders v1.0.1/go.mod h1:V33CcVmaQ4gRUmHKwq1fiTXuf8Gp/qjQBUL5tHPmvbA=
-github.com/gobuffalo/syncx v0.0.0-20181120191700-98333ab04150/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
-github.com/gobuffalo/syncx v0.0.0-20181120194010-558ac7de985f h1:S5EeH1reN93KR0L6TQvkRpu9YggCYXrUqFh1iEgvdC0=
-github.com/gobuffalo/syncx v0.0.0-20181120194010-558ac7de985f/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
-github.com/gobuffalo/tags v2.0.11+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY=
-github.com/gobuffalo/tags v2.0.14+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY=
-github.com/gobuffalo/tags v2.0.15+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY=
-github.com/gobuffalo/uuid v2.0.3+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE=
-github.com/gobuffalo/uuid v2.0.4+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE=
-github.com/gobuffalo/uuid v2.0.5+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE=
-github.com/gobuffalo/validate v2.0.3+incompatible/go.mod h1:N+EtDe0J8252BgfzQUChBgfd6L93m9weay53EWFVsMM=
-github.com/gobuffalo/x v0.0.0-20181003152136-452098b06085/go.mod h1:WevpGD+5YOreDJznWevcn8NTmQEW5STSBgIkpkjzqXc=
-github.com/gobuffalo/x v0.0.0-20181007152206-913e47c59ca7/go.mod h1:9rDPXaB3kXdKWzMc4odGQQdG2e2DIEmANy5aSJ9yesY=
-github.com/gofrs/uuid v3.1.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
-github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github v15.0.0+incompatible h1:jlPg2Cpsxb/FyEV/MFiIE9tW/2RAevQNZDPeHbf5a94=
github.com/google/go-github v15.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 h1:zLTLjkaOFEFIOxY5BWLFLwh+cL8vOBW4XJ2aqLE/Tf0=
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
-github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
-github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
-github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY=
-github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
-github.com/gorilla/sessions v1.1.2/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
-github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
+github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
+github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
+github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ=
-github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
+github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
+github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
-github.com/jmoiron/sqlx v0.0.0-20180614180643-0dae4fefe7c0/go.mod h1:IiEW3SEiiErVyFdH8NTuWjSifiEQKUoyK3LNqr2kCHU=
-github.com/joho/godotenv v1.2.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
-github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
-github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
-github.com/karrick/godirwalk v1.7.5/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34=
-github.com/karrick/godirwalk v1.7.7 h1:lLkPCA+C0u1pI4fLFseaupvh5/THlPJIqSPmnGGViKs=
-github.com/karrick/godirwalk v1.7.7/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY=
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/konsorten/go-windows-terminal-sequences v0.0.0-20180402223658-b729f2633dfe/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.15.14 h1:i7WCKDToww0wA+9qrUZ1xOjp218vfFo3nTU6UHp+gOc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ=
github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/markbates/deplist v1.0.4/go.mod h1:gRRbPbbuA8TmMiRvaOzUlRfzfjeCCBqX2A6arxN01MM=
-github.com/markbates/deplist v1.0.5/go.mod h1:gRRbPbbuA8TmMiRvaOzUlRfzfjeCCBqX2A6arxN01MM=
-github.com/markbates/going v1.0.2/go.mod h1:UWCk3zm0UKefHZ7l8BNqi26UyiEMniznk8naLdTcy6c=
-github.com/markbates/grift v1.0.4/go.mod h1:wbmtW74veyx+cgfwFhlnnMWqhoz55rnHR47oMXzsyVs=
-github.com/markbates/hmax v1.0.0/go.mod h1:cOkR9dktiESxIMu+65oc/r/bdY4bE8zZw3OLhLx0X2c=
-github.com/markbates/inflect v1.0.0/go.mod h1:oTeZL2KHA7CUX6X+fovmK9OvIOFuqu0TwdQrZjLTh88=
-github.com/markbates/inflect v1.0.1/go.mod h1:uv3UVNBe5qBIfCm8O8Q+DW+S1EopeyINj+Ikhc7rnCk=
-github.com/markbates/inflect v1.0.3/go.mod h1:1fR9+pO2KHEO9ZRtto13gDwwZaAKstQzferVeWqbgNs=
-github.com/markbates/inflect v1.0.4/go.mod h1:1fR9+pO2KHEO9ZRtto13gDwwZaAKstQzferVeWqbgNs=
-github.com/markbates/oncer v0.0.0-20180924031910-e862a676800b/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
-github.com/markbates/oncer v0.0.0-20180924034138-723ad0170a46/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
-github.com/markbates/oncer v0.0.0-20181014194634-05fccaae8fc4/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
-github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2 h1:JgVTCPf0uBVcUSWpyXmGpgOc62nK5HWUBKAGc3Qqa5k=
-github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
-github.com/markbates/refresh v1.4.10/go.mod h1:NDPHvotuZmTmesXxr95C9bjlw1/0frJwtME2dzcVKhc=
-github.com/markbates/safe v1.0.0/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
-github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI=
-github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
-github.com/markbates/sigtx v1.0.0/go.mod h1:QF1Hv6Ic6Ca6W+T+DL0Y/ypborFKyvUY9HmuCD4VeTc=
-github.com/markbates/willie v1.0.9/go.mod h1:fsrFVWl91+gXpx/6dv715j7i11fYPfZ9ZGfH0DQzY7w=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
-github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
-github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=
-github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
-github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
-github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/monoculum/formam v0.0.0-20180901015400-4e68be1d79ba/go.mod h1:RKgILGEJq24YyJ2ban8EO0RUVSJlF1pGsEvoLEACr/Q=
-github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
+github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
+github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
+github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
+github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
-github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
-github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.12.1 h1:mFwc4LvZ0xpSvDZ3E+k8Yte0hLOMxXUlP+yXtJqkYfQ=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
-github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
-github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
+github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
-github.com/onsi/gomega v1.10.4 h1:NiTx7EEvBzu9sFOD1zORteLSt3o8gnlvZZwSE9TnY9U=
-github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.17.0 h1:9Luw4uT5HTjHTN8+aNcSThgH1vdXnmdJ8xIfZ4wyTRE=
+github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
+github.com/open-policy-agent/opa v0.50.2 h1:iD2kKLFkflgSCTMtrC/3jLmOQ7IWyDXMg6+VQA0tSC0=
+github.com/open-policy-agent/opa v0.50.2/go.mod h1:9jKfDk0L5b9rnhH4M0nq10cGHbYOxqygxzTT3dsvhec=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
+github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
+github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
+github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
+github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rhysd/go-github-selfupdate v0.0.0-20180520142321-41c1bbb0804a h1:YNh/SV+Z0p7kQDUE9Ux+46ruTucvQP43XB06DfZa8Es=
github.com/rhysd/go-github-selfupdate v0.0.0-20180520142321-41c1bbb0804a/go.mod h1:mOFQaTkPA4plTgFW6Gnejb/RsEIqAoIqOACC2XaZX04=
-github.com/rogpeppe/go-internal v1.0.0 h1:o4VLZ5jqHE+HahLT6drNtSGTrrUA3wPBmtpgqtdbClo=
-github.com/rogpeppe/go-internal v1.0.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
-github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
-github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs=
-github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
-github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
-github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
-github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
-github.com/shurcooL/highlight_go v0.0.0-20170515013102-78fb10f4a5f8/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
-github.com/shurcooL/octicon v0.0.0-20180602230221-c42b0e3b24d9/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
-github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
-github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
-github.com/sirupsen/logrus v1.1.0/go.mod h1:zrgwTnHtNr00buQ1vSptGe8m1f/BbgsPukg8qsT7A+A=
-github.com/sirupsen/logrus v1.1.1/go.mod h1:zrgwTnHtNr00buQ1vSptGe8m1f/BbgsPukg8qsT7A+A=
-github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
-github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
-github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
-github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s=
-github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
-github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
+github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
+github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.2.1/go.mod h1:P4AexN0a+C9tGAnUFNwDMYYZv3pjFuvmeiMyKRaNVlI=
-github.com/spf13/viper v1.3.1/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
-github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes=
+github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
-github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
-github.com/unrolled/secure v0.0.0-20180918153822-f340ee86eb8b/go.mod h1:mnPT77IAdsi/kV7+Es7y+pXALeV3h7G6dQF6mNYjcLA=
-github.com/unrolled/secure v0.0.0-20181005190816-ff9db2ff917f/go.mod h1:mnPT77IAdsi/kV7+Es7y+pXALeV3h7G6dQF6mNYjcLA=
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181001203147-e3636079e1a4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181024171144-74cb1d3d52f4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181025113841-85e1b3f9139a/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181025213731-e84da0312774/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/yashtewari/glob-intersection v0.1.0 h1:6gJvMYQlTDOL3dMsPF6J0+26vwX9MB8/1q3uAdhmTrg=
+github.com/yashtewari/glob-intersection v0.1.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
+github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
+golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug=
+golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180816102801-aaf60122140d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180921000356-2f5d2388922f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181207154023-610586996380/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb h1:eBmm0M9fYhWpKZLjQUUKka/LtIxf46G4fxeEz5KJr9U=
-golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc h1:3ElrZeO6IBP+M8kgu5YFwRo92Gqr+zBg3aooYQ6ziqU=
-golang.org/x/oauth2 v0.0.0-20180724155351-3d292e4d0cdc/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
+golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
+golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180921163948-d47a0f339242/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180927150500-dad3d9fb7b6e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181005133103-4497e2df6f9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181011152604-fa43e7bc11ba/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181022134430-8a28ead16f52/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181024145615-5cd93ef61a7c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181025063200-d989b31c8746/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181026064943-731415f00dce/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181106135930-3a76605856fd/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181206074257-70b957f3b65e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181003024731-2f84ea8ef872/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181006002542-f60d9635b16a/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181008205924-a2b3f7f249e9/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181013182035-5e66757b835f/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181017214349-06f26fdaaa28/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181024171208-a2dc47679d30/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181026183834-f60e5f99f081/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181105230042-78dc5bac0cac/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181107215632-34b416bd17b3/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181114190951-94339b83286c/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181119130350-139d099f6620/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181127195227-b4e97c0ed882/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181127232545-e782529d0ddd/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181203210056-e5f3ab76ea4b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181205224935-3576414c54a4/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181206194817-bcd4e47d0288/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181207183836-8bc39b988060/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181212172921-837e80568c09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 h1:1mMox4TgefDwqluYCv677yNXwlfTkija4owZve/jr78=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
+golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
+google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
-gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
+google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
-gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
-gopkg.in/mail.v2 v2.0.0-20180731213649-a0242b2233b4/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c h1:grhR+C34yXImVGp7EzNk+DTIk+323eIUWOmEevy6bDo=
-gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E=
-gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
+gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/install.sh b/install.sh
index cac9c1989..16a9993f0 100755
--- a/install.sh
+++ b/install.sh
@@ -51,7 +51,7 @@ esac
RELEASE_URL="${GITHUB_BASE_URL}/releases/download/v${VERSION}/circleci-cli_${VERSION}_${OS}_amd64.tar.gz"
# Download & unpack the release tarball.
-curl -sL --retry 3 "${RELEASE_URL}" | tar zx --strip 1
+curl --ssl-reqd -sL --retry 3 "${RELEASE_URL}" | tar zx --strip 1
echo "Installing to $DESTDIR"
install circleci "$DESTDIR"
diff --git a/integration_tests/features/circleci_config.feature b/integration_tests/features/circleci_config.feature
index d9085cc2b..9628d3bf3 100644
--- a/integration_tests/features/circleci_config.feature
+++ b/integration_tests/features/circleci_config.feature
@@ -15,6 +15,239 @@ Feature: Config checking
Then the exit status should be 0
And the output should contain "Config file at config.yml is valid."
+ Scenario: Checking a valid config file with an orb
+ Given a file named "config.yml" with:
+ """
+ version: 2.1
+
+ orbs:
+ node: circleci/node@5.0.3
+
+ jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+ workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
+ """
+ When I run `circleci config validate --skip-update-check -c config.yml`
+ Then the exit status should be 0
+ And the output should contain "Config file at config.yml is valid"
+
+ Scenario: Checking a valid config against the k9s server
+ Given a file named "config.yml" with:
+ """
+ version: 2.1
+
+ jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+ workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
+ """
+ When I run `circleci --host https://k9s.sphereci.com config validate --skip-update-check -c config.yml`
+ Then the exit status should be 0
+ And the output should contain "Config file at config.yml is valid"
+
+ Scenario: Checking a valid config file with an orb
+ Given a file named "config.yml" with:
+ """
+ version: 2.1
+
+ orbs:
+ node: circleci/node@5.0.3
+
+ jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+ workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
+ """
+ When I run `circleci config validate --skip-update-check -c config.yml`
+ Then the exit status should be 0
+ And the output should contain "Config file at config.yml is valid"
+
+ Scenario: Checking a valid config file with a private org
+ Given a file named "config.yml" with:
+ """
+ version: 2.1
+
+ orbs:
+ node: circleci/node@5.0.3
+
+ jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+ workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
+ """
+ When I run `circleci config validate --skip-update-check --org-id bb604b45-b6b0-4b81-ad80-796f15eddf87 -c config.yml`
+ Then the output should contain "Config file at config.yml is valid"
+ And the exit status should be 0
+
+ Scenario: Checking a valid config file with a non-existant orb
+ Given a file named "config.yml" with:
+ """
+ version: 2.1
+
+ orbs:
+ node: circleci/doesnt-exist@5.0.3
+
+ jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+ workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
+ """
+ When I run `circleci config validate --skip-update-check -c config.yml`
+ Then the exit status should be 255
+ And the output should contain "config compilation contains errors"
+
+ Scenario: Checking a valid config file with pipeline-parameters
+ Given a file named "config.yml" with:
+ """
+ version: 2.1
+
+ parameters:
+ foo:
+ type: string
+ default: "bar"
+
+ jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+ echo << pipeline.parameters.foo >>
+ workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
+ """
+ When I run `circleci config process config.yml --pipeline-parameters "foo: fighters"`
+ Then the output should contain "fighters"
+ And the exit status should be 0
+
+ Scenario: Testing new type casting works as expected
+ Given a file named "config.yml" with:
+ """
+ version: 2.1
+
+ jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ parameters:
+ an-integer:
+ description: a test case to ensure parameters are passed correctly
+ type: integer
+ default: -1
+ steps:
+ - unless:
+ condition:
+ equal: [<< parameters.an-integer >>, -1]
+ steps:
+ - run: echo "<< parameters.an-integer >> - test"
+ workflows:
+ main-workflow:
+ jobs:
+ - datadog-hello-world:
+ an-integer: << pipeline.number >>
+ """
+ When I run `circleci config process config.yml`
+ Then the output should contain "1 - test"
+ And the exit status should be 0
+
+ Scenario: Checking a valid config file with default pipeline params
+ Given a file named "config.yml" with:
+ """
+ version: 2.1
+
+ parameters:
+ foo:
+ type: string
+ default: "bar"
+
+ jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+ echo << pipeline.parameters.foo >>
+ workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
+ """
+ When I run `circleci config process config.yml`
+ Then the output should contain "bar"
+ And the exit status should be 0
+
+ Scenario: Checking a valid config file with file pipeline-parameters
+ Given a file named "config.yml" with:
+ """
+ version: 2.1
+
+ parameters:
+ foo:
+ type: string
+ default: "bar"
+
+ jobs:
+ datadog-hello-world:
+ docker:
+ - image: cimg/base:stable
+ steps:
+ - run: |
+ echo "doing something really cool"
+ echo << pipeline.parameters.foo >>
+ workflows:
+ datadog-hello-world:
+ jobs:
+ - datadog-hello-world
+ """
+ And I write to "params.yml" with:
+ """
+ foo: "totallyawesome"
+ """
+ When I run `circleci config process config.yml --pipeline-parameters params.yml`
+ Then the output should contain "totallyawesome"
+ And the exit status should be 0
+
+
Scenario: Checking an invalid config file
Given a file named "config.yml" with:
"""
diff --git a/integration_tests/features/root_commands.feature b/integration_tests/features/root_commands.feature
index 13da2602f..3b3fa5d61 100644
--- a/integration_tests/features/root_commands.feature
+++ b/integration_tests/features/root_commands.feature
@@ -7,11 +7,7 @@ Feature: Root Commands
When I run `circleci help`
Then the output should contain:
"""
- Use CircleCI from the command line.
-
- This project is the seed for CircleCI's new command-line application.
-
- For more help, see the documentation here: https://circleci.com/docs/2.0/local-cli/
+ circleci
"""
And the exit status should be 0
diff --git a/local/local.go b/local/local.go
index 01fa87dce..320789c66 100644
--- a/local/local.go
+++ b/local/local.go
@@ -4,16 +4,14 @@ import (
"encoding/json"
"fmt"
"io"
- "io/ioutil"
"os"
"os/exec"
"path"
"regexp"
+ "strings"
"syscall"
- "github.com/CircleCI-Public/circleci-cli/api"
- "github.com/CircleCI-Public/circleci-cli/api/graphql"
- "github.com/CircleCI-Public/circleci-cli/pipeline"
+ "github.com/CircleCI-Public/circleci-cli/config"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/pkg/errors"
"github.com/spf13/pflag"
@@ -23,30 +21,26 @@ var picardRepo = "circleci/picard"
const DefaultConfigPath = ".circleci/config.yml"
-type buildAgentSettings struct {
- LatestSha256 string
-}
-
-func UpdateBuildAgent() error {
- latestSha256, err := findLatestPicardSha()
-
- if err != nil {
- return err
- }
-
- fmt.Printf("Latest build agent is version %s\n", latestSha256)
-
- return nil
-}
-
-func Execute(flags *pflag.FlagSet, cfg *settings.Config) error {
+func Execute(flags *pflag.FlagSet, cfg *settings.Config, args []string) error {
+ var err error
+ var configResponse *config.ConfigResponse
processedArgs, configPath := buildAgentArguments(flags)
- orgSlug, _ := flags.GetString("org-slug")
- cl := graphql.NewClient(cfg.HTTPClient, cfg.Host, cfg.Endpoint, cfg.Token, cfg.Debug)
- configResponse, err := api.ConfigQuery(cl, configPath, orgSlug, nil, pipeline.LocalPipelineValues())
- if err != nil {
- return err
+ compiler := config.New(cfg)
+
+ //if no orgId provided use org slug
+ orgID, _ := flags.GetString("org-id")
+ if strings.TrimSpace(orgID) != "" {
+ configResponse, err = compiler.ConfigQuery(configPath, orgID, nil, config.LocalPipelineValues())
+ if err != nil {
+ return err
+ }
+ } else {
+ orgSlug, _ := flags.GetString("org-slug")
+ configResponse, err = compiler.ConfigQuery(configPath, orgSlug, nil, config.LocalPipelineValues())
+ if err != nil {
+ return err
+ }
}
if !configResponse.Valid {
@@ -78,13 +72,15 @@ func Execute(flags *pflag.FlagSet, cfg *settings.Config) error {
return err
}
- image, err := picardImage(os.Stdout)
+ picardVersion, _ := flags.GetString("build-agent-version")
+ image, err := picardImage(os.Stdout, picardVersion)
if err != nil {
return errors.Wrap(err, "Could not find picard image")
}
- arguments := generateDockerCommand(processedConfigPath, image, pwd, processedArgs...)
+ job := args[0]
+ arguments := generateDockerCommand(processedConfigPath, image, pwd, job, processedArgs...)
if cfg.Debug {
_, err = fmt.Fprintf(os.Stderr, "Starting docker with args: %s", arguments)
@@ -109,7 +105,6 @@ func Execute(flags *pflag.FlagSet, cfg *settings.Config) error {
// are public in the original command.
func AddFlagsForDocumentation(flags *pflag.FlagSet) {
flags.StringP("config", "c", DefaultConfigPath, "config file")
- flags.String("job", "build", "job to be executed")
flags.Int("node-total", 1, "total number of parallel nodes")
flags.Int("index", 0, "node index of parallelism")
flags.Bool("skip-checkout", true, "use local path as-is")
@@ -136,7 +131,7 @@ func buildAgentArguments(flags *pflag.FlagSet) ([]string, string) {
// build a list of all supplied flags, that we will pass on to build-agent
flags.Visit(func(flag *pflag.Flag) {
- if flag.Name != "org-slug" && flag.Name != "config" && flag.Name != "debug" {
+ if flag.Name != "build-agent-version" && flag.Name != "org-slug" && flag.Name != "config" && flag.Name != "debug" && flag.Name != "org-id" {
result = append(result, unparseFlag(flags, flag)...)
}
})
@@ -147,29 +142,42 @@ func buildAgentArguments(flags *pflag.FlagSet) ([]string, string) {
return result, configPath
}
-func picardImage(output io.Writer) (string, error) {
+func picardImage(output io.Writer, picardVersion string) (string, error) {
- sha, err := loadCurrentBuildAgentSha()
+ fmt.Fprintf(output, "Fetching latest build environment...\n")
+ sha, err := getPicardSha(output, picardVersion)
if err != nil {
- fmt.Printf("Failed to load build agent settings: %s\n", err)
+ return "", err
}
- if sha == "" {
-
- fmt.Println("Downloading latest CircleCI build agent...")
+ _, _ = fmt.Fprintf(output, "Docker image digest: %s\n", sha)
+ return fmt.Sprintf("%s@%s", picardRepo, sha), nil
+}
- var err error
+func getPicardSha(output io.Writer, picardVersion string) (string, error) {
+ // If the version was passed as argument, we take it
+ if picardVersion != "" {
+ return picardVersion, nil
+ }
- sha, err = findLatestPicardSha()
+ var sha string
+ var err error
- if err != nil {
- return "", err
- }
+ sha, err = loadBuildAgentShaFromConfig()
+ if sha != "" && err == nil {
+ return sha, nil
+ }
+ if err != nil && !os.IsNotExist(err) {
+ fmt.Fprintf(output, "Unable to parse JSON file %s because: %s\n", buildAgentSettingsPath(), err)
+ fmt.Fprintf(output, "Falling back to latest build-agent version\n")
+ }
+ sha, err = findLatestPicardSha()
+ if err != nil {
+ return "", err
}
- _, _ = fmt.Fprintf(output, "Docker image digest: %s\n", sha)
- return fmt.Sprintf("%s@%s", picardRepo, sha), nil
+ return sha, nil
}
func ensureDockerIsAvailable() (string, error) {
@@ -210,42 +218,14 @@ func findLatestPicardSha() (string, error) {
return "", fmt.Errorf("failed to parse sha256 from docker pull output")
}
- // This function still lives in cmd/build.go
- err = storeBuildAgentSha(latest)
-
- if err != nil {
- return "", err
- }
-
return latest, nil
}
-func buildAgentSettingsPath() string {
- return path.Join(settings.SettingsPath(), "build_agent_settings.json")
-}
-
-func storeBuildAgentSha(sha256 string) error {
- agentSettings := buildAgentSettings{
- LatestSha256: sha256,
- }
-
- settingsJSON, err := json.Marshal(agentSettings)
-
- if err != nil {
- return errors.Wrap(err, "Failed to serialize build agent settings")
- }
-
- if err = os.MkdirAll(settings.SettingsPath(), 0700); err != nil {
- return errors.Wrap(err, "Could not create settings directory")
- }
-
- err = ioutil.WriteFile(buildAgentSettingsPath(), settingsJSON, 0644)
-
- return errors.Wrap(err, "Failed to write build agent settings file")
+type buildAgentSettings struct {
+ LatestSha256 string
}
-func loadCurrentBuildAgentSha() (string, error) {
-
+func loadBuildAgentShaFromConfig() (string, error) {
if _, err := os.Stat(buildAgentSettingsPath()); os.IsNotExist(err) {
// Settings file does not exist.
return "", nil
@@ -267,6 +247,10 @@ func loadCurrentBuildAgentSha() (string, error) {
return settings.LatestSha256, nil
}
+func buildAgentSettingsPath() string {
+ return path.Join(settings.SettingsPath(), "build_agent_settings.json")
+}
+
// Write data to a temp file, and return the path to that file.
func writeStringToTempFile(data string) (string, error) {
// It's important to specify `/tmp` here as the location of the temp file.
@@ -275,7 +259,7 @@ func writeStringToTempFile(data string) (string, error) {
// > The path /var/folders/q0/2g2lcf6j79df6vxqm0cg_0zm0000gn/T/287575618-config.yml
// > is not shared from OS X and is not known to Docker.
// Docker has `/tmp` shared by default.
- f, err := ioutil.TempFile("/tmp", "*_circleci_config.yml")
+ f, err := os.CreateTemp("/tmp", "*_circleci_config.yml")
if err != nil {
return "", errors.Wrap(err, "Error creating temporary config file")
@@ -288,7 +272,7 @@ func writeStringToTempFile(data string) (string, error) {
return f.Name(), nil
}
-func generateDockerCommand(configPath, image, pwd string, arguments ...string) []string {
+func generateDockerCommand(configPath, image, pwd string, job string, arguments ...string) []string {
const configPathInsideContainer = "/tmp/local_build_config.yml"
core := []string{"docker", "run", "--interactive", "--tty", "--rm",
"--volume", "/var/run/docker.sock:/var/run/docker.sock",
@@ -296,7 +280,7 @@ func generateDockerCommand(configPath, image, pwd string, arguments ...string) [
"--volume", fmt.Sprintf("%s:%s", pwd, pwd),
"--volume", fmt.Sprintf("%s:/root/.circleci", settings.SettingsPath()),
"--workdir", pwd,
- image, "circleci", "build", "--config", configPathInsideContainer}
+ image, "circleci", "build", "--config", configPathInsideContainer, "--job", job}
return append(core, arguments...)
}
diff --git a/local/local_test.go b/local/local_test.go
index b00748de8..598806ca9 100644
--- a/local/local_test.go
+++ b/local/local_test.go
@@ -1,7 +1,7 @@
package local
import (
- "io/ioutil"
+ "io"
"os"
. "github.com/onsi/ginkgo"
@@ -17,7 +17,7 @@ var _ = Describe("build", func() {
It("can generate a command line", func() {
home, err := os.UserHomeDir()
Expect(err).NotTo(HaveOccurred())
- Expect(generateDockerCommand("/config/path", "docker-image-name", "/current/directory", "extra-1", "extra-2")).To(ConsistOf(
+ Expect(generateDockerCommand("/config/path", "docker-image-name", "/current/directory", "build", "extra-1", "extra-2")).To(ConsistOf(
"docker",
"run",
"--interactive",
@@ -30,6 +30,7 @@ var _ = Describe("build", func() {
"--workdir", "/current/directory",
"docker-image-name", "circleci", "build",
"--config", "/tmp/local_build_config.yml",
+ "--job", "build",
"extra-1", "extra-2",
))
})
@@ -38,7 +39,7 @@ var _ = Describe("build", func() {
path, err := writeStringToTempFile("cynosure")
Expect(err).NotTo(HaveOccurred())
defer os.Remove(path)
- Expect(ioutil.ReadFile(path)).To(BeEquivalentTo("cynosure"))
+ Expect(os.ReadFile(path)).To(BeEquivalentTo("cynosure"))
})
})
@@ -50,7 +51,7 @@ var _ = Describe("build", func() {
// add a 'debug' flag - the build command will inherit this from the
// root command when not testing in isolation.
flags.Bool("debug", false, "Enable debug logging.")
- flags.SetOutput(ioutil.Discard)
+ flags.SetOutput(io.Discard)
err := flags.Parse(args)
return flags, err
}
@@ -92,9 +93,9 @@ var _ = Describe("build", func() {
}),
Entry("many args", TestCase{
- input: []string{"--job", "horse", "--config", "foo", "--index", "9", "d"},
+ input: []string{"--config", "foo", "--index", "9", "d"},
expectedConfigPath: "foo",
- expectedArgs: []string{"--index", "9", "--job", "horse", "d"},
+ expectedArgs: []string{"--index", "9", "d"},
}),
Entry("many args, multiple envs", TestCase{
@@ -122,32 +123,4 @@ var _ = Describe("build", func() {
}))
})
-
- Describe("loading settings", func() {
-
- var (
- tempHome string
- )
-
- BeforeEach(func() {
- var err error
- tempHome, err = ioutil.TempDir("", "circleci-cli-test-")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(os.Setenv("HOME", tempHome)).To(Succeed())
-
- })
-
- AfterEach(func() {
- Expect(os.RemoveAll(tempHome)).To(Succeed())
- })
-
- It("can load settings", func() {
- Expect(storeBuildAgentSha("deipnosophist")).To(Succeed())
- Expect(loadCurrentBuildAgentSha()).To(Equal("deipnosophist"))
- image, err := picardImage(ioutil.Discard)
- Expect(err).ToNot(HaveOccurred())
- Expect(image).To(Equal("circleci/picard@deipnosophist"))
- })
- })
})
diff --git a/md_docs/md_docs.go b/md_docs/md_docs.go
index f60d1180e..36cef982a 100644
--- a/md_docs/md_docs.go
+++ b/md_docs/md_docs.go
@@ -15,14 +15,13 @@ import (
var introHeader = `
[Readme](https://github.com/CircleCI-Public/circleci-cli#readme) |
-[Code of Conduct](https://github.com/CircleCI-Public/circleci-cli/blob/master/CODE_OF_CONDUCT.md) |
-[Contribution Guidelines](https://github.com/CircleCI-Public/circleci-cli/blob/master/CONTRIBUTING.md) |
-[Hacking](https://github.com/CircleCI-Public/circleci-cli/blob/master/HACKING.md)
+[Code of Conduct](https://github.com/CircleCI-Public/circleci-cli/blob/main/CODE_OF_CONDUCT.md) |
+[Contribution Guidelines](https://github.com/CircleCI-Public/circleci-cli/blob/main/CONTRIBUTING.md) |
+[Hacking](https://github.com/CircleCI-Public/circleci-cli/blob/main/HACKING.md)
[![CircleCI](https://circleci.com/gh/CircleCI-Public/circleci-cli.svg?style=svg)](https://circleci.com/gh/CircleCI-Public/circleci-cli)
[![GitHub release](https://img.shields.io/github/tag/CircleCI-Public/circleci-cli.svg?label=latest)](https://github.com/CircleCI-Public/circleci-cli/releases)
[![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)](https://godoc.org/github.com/CircleCI-Public/circleci-cli)
-[![Codecov](https://codecov.io/gh/CircleCI-Public/circleci-cli/branch/master/graph/badge.svg)](https://codecov.io/gh/CircleCI-Public/circleci-cli)
[![License](https://img.shields.io/badge/license-MIT-red.svg)](./LICENSE)
`
@@ -171,6 +170,15 @@ func GenMarkdownTree(cmd *cobra.Command, dir string) error {
// GenMarkdownTreeCustom is the the same as GenMarkdownTree, but
// with custom filePrepender and linkHandler.
func GenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHandler func(string) string) error {
+ // There is a problem with the tool transforming the markdown into html, the tool transforms the
+ // circleci ascii art bad. By forcing it into a codeblock the formatting problem disappear
+ if cmd.Root() == cmd {
+ oldLong := cmd.Long
+ cmd.Long = fmt.Sprintf("```%s\n```", oldLong)
+ defer func() {
+ cmd.Long = oldLong
+ }()
+ }
for _, c := range cmd.Commands() {
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
continue
diff --git a/mock/http.go b/mock/http.go
index 8f93a6a81..f905ca0a3 100644
--- a/mock/http.go
+++ b/mock/http.go
@@ -1,7 +1,7 @@
package mock
import (
- "io/ioutil"
+ "io"
"net/http"
"strings"
)
@@ -23,6 +23,6 @@ func NewHTTPClient(f func(*http.Request) (*http.Response, error)) *http.Client {
func NewHTTPResponse(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
- Body: ioutil.NopCloser(strings.NewReader(body)),
+ Body: io.NopCloser(strings.NewReader(body)),
}
}
diff --git a/process/process.go b/process/process.go
index cb7745ae1..216f9ad69 100644
--- a/process/process.go
+++ b/process/process.go
@@ -2,7 +2,7 @@ package process
import (
"fmt"
- "io/ioutil"
+ "os"
"path/filepath"
"regexp"
"strings"
@@ -31,7 +31,7 @@ func MaybeIncludeFile(s string, orbDirectory string) (string, error) {
}
filepath := filepath.Join(orbDirectory, subMatch)
- file, err := ioutil.ReadFile(filepath)
+ file, err := os.ReadFile(filepath)
if err != nil {
return "", fmt.Errorf("could not open %s for inclusion", filepath)
}
diff --git a/prompt/prompt.go b/prompt/prompt.go
index 90934b019..cb2bd52dd 100644
--- a/prompt/prompt.go
+++ b/prompt/prompt.go
@@ -1,48 +1,38 @@
package prompt
-import "github.com/AlecAivazis/survey/v2"
+import (
+ "github.com/erikgeiser/promptkit/confirmation"
+ "github.com/erikgeiser/promptkit/textinput"
+)
// ReadSecretStringFromUser can be used to read a value from the user by masking their input.
// It's useful for token input in our case.
func ReadSecretStringFromUser(message string) (string, error) {
secret := ""
- prompt := &survey.Password{
- Message: message,
- }
- err := survey.AskOne(prompt, &secret)
+ input := textinput.New(message)
+ input.Hidden = true
+ secret, err := input.RunPrompt()
if err != nil {
return "", err
}
-
return secret, nil
}
// ReadStringFromUser can be used to read any value from the user or the defaultValue when provided.
func ReadStringFromUser(message string, defaultValue string) string {
- token := ""
- prompt := &survey.Input{
- Message: message,
- }
-
- if defaultValue != "" {
- prompt.Default = defaultValue
- }
-
- err := survey.AskOne(prompt, &token)
+ input := textinput.New(message)
+ input.Placeholder = defaultValue
+ input.InitialValue = defaultValue
+ result, err := input.RunPrompt()
if err != nil {
panic(err)
}
-
- return token
+ return result
}
// AskUserToConfirm will prompt the user to confirm with the provided message.
func AskUserToConfirm(message string) bool {
- result := true
- prompt := &survey.Confirm{
- Message: message,
- }
-
- err := survey.AskOne(prompt, &result)
+ input := confirmation.New(message, confirmation.No)
+ result, err := input.RunPrompt()
return err == nil && result
}
diff --git a/settings/settings.go b/settings/settings.go
index f7c7a3d00..36928fac5 100644
--- a/settings/settings.go
+++ b/settings/settings.go
@@ -5,8 +5,8 @@ import (
"crypto/x509"
"errors"
"fmt"
- "io/ioutil"
"net/http"
+ "net/url"
"os"
"path"
"path/filepath"
@@ -14,20 +14,22 @@ import (
"strings"
"time"
- "github.com/CircleCI-Public/circleci-cli/data"
yaml "gopkg.in/yaml.v3"
+
+ "github.com/CircleCI-Public/circleci-cli/data"
)
// Config is used to represent the current state of a CLI instance.
type Config struct {
Host string `yaml:"host"`
+ DlHost string `yaml:"-"`
Endpoint string `yaml:"endpoint"`
Token string `yaml:"token"`
RestEndpoint string `yaml:"rest_endpoint"`
TLSCert string `yaml:"tls_cert"`
TLSInsecure bool `yaml:"tls_insecure"`
HTTPClient *http.Client `yaml:"-"`
- Data *data.YML `yaml:"-"`
+ Data *data.DataBag `yaml:"-"`
Debug bool `yaml:"-"`
Address string `yaml:"-"`
FileUsed string `yaml:"-"`
@@ -58,7 +60,7 @@ func (upd *UpdateCheck) Load() error {
upd.FileUsed = path
- content, err := ioutil.ReadFile(path) // #nosec
+ content, err := os.ReadFile(path) // #nosec
if err != nil {
return err
}
@@ -74,7 +76,7 @@ func (upd *UpdateCheck) WriteToDisk() error {
return err
}
- err = ioutil.WriteFile(upd.FileUsed, enc, 0600)
+ err = os.WriteFile(upd.FileUsed, enc, 0600)
return err
}
@@ -99,7 +101,7 @@ func (cfg *Config) LoadFromDisk() error {
cfg.FileUsed = path
- content, err := ioutil.ReadFile(path) // #nosec
+ content, err := os.ReadFile(path) // #nosec
if err != nil {
return err
}
@@ -119,7 +121,7 @@ func (cfg *Config) WriteToDisk() error {
return err
}
- err = ioutil.WriteFile(cfg.FileUsed, enc, 0600)
+ err = os.WriteFile(cfg.FileUsed, enc, 0600)
return err
}
@@ -209,7 +211,7 @@ func (cfg *Config) WithHTTPClient() error {
return fmt.Errorf("invalid tls cert provided: %s", err.Error())
}
- pemData, err := ioutil.ReadFile(cfg.TLSCert)
+ pemData, err := os.ReadFile(cfg.TLSCert)
if err != nil {
return fmt.Errorf("unable to read tls cert: %s", err.Error())
}
@@ -222,15 +224,17 @@ func (cfg *Config) WithHTTPClient() error {
tlsConfig.RootCAs = pool
}
+ // clone default http transport to retain default transport config values
+ customTransport := http.DefaultTransport.(*http.Transport).Clone()
+ customTransport.ExpectContinueTimeout = time.Second
+ customTransport.IdleConnTimeout = 90 * time.Second
+ customTransport.MaxIdleConns = 10
+ customTransport.TLSHandshakeTimeout = 10 * time.Second
+ customTransport.TLSClientConfig = tlsConfig
+
cfg.HTTPClient = &http.Client{
- Timeout: 30 * time.Second,
- Transport: &http.Transport{
- ExpectContinueTimeout: 1 * time.Second,
- IdleConnTimeout: 90 * time.Second,
- MaxIdleConns: 10,
- TLSHandshakeTimeout: 10 * time.Second,
- TLSClientConfig: tlsConfig,
- },
+ Timeout: 60 * time.Second,
+ Transport: customTransport,
}
return nil
@@ -273,3 +277,26 @@ func isWorldWritable(info os.FileInfo) bool {
sysPerms := mode[len(mode)-3:]
return strings.Contains(sysPerms, "w")
}
+
+// ServerURL retrieves and formats a ServerURL from our restEndpoint and host.
+func (cfg *Config) ServerURL() (*url.URL, error) {
+ var URL string
+
+ if !strings.HasSuffix(cfg.RestEndpoint, "/") {
+ URL = fmt.Sprintf("%s/", cfg.RestEndpoint)
+ } else {
+ URL = cfg.RestEndpoint
+ }
+
+ serverURL, err := url.Parse(cfg.Host)
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err = serverURL.Parse(URL)
+ if err != nil {
+ return nil, err
+ }
+
+ return serverURL, nil
+}
diff --git a/settings/settings_test.go b/settings/settings_test.go
index 0e72224d2..b98f38060 100644
--- a/settings/settings_test.go
+++ b/settings/settings_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/CircleCI-Public/circleci-cli/settings"
+ "gotest.tools/v3/assert"
)
func TestWithHTTPClient(t *testing.T) {
@@ -80,3 +81,15 @@ func TestWithHTTPClient(t *testing.T) {
})
}
}
+
+func TestServerURL(t *testing.T) {
+ config := settings.Config{
+ Host: "/host",
+ RestEndpoint: "/restendpoint",
+ }
+
+ serverURL, err := config.ServerURL()
+
+ assert.NilError(t, err)
+ assert.Equal(t, serverURL.String(), "/restendpoint/")
+}
diff --git a/update/update.go b/update/update.go
index a82f126f0..77e1aa9ca 100644
--- a/update/update.go
+++ b/update/update.go
@@ -136,20 +136,20 @@ func checkFromHomebrew(check *Options) error {
// HomebrewOutdated wraps the JSON output from running `brew outdated --json=v2`
// We're specifically looking for this kind of structured data from the command:
//
-// {
-// "formulae": [
-// {
-// "name": "circleci",
-// "installed_versions": [
-// "0.1.1248"
-// ],
-// "current_version": "0.1.3923",
-// "pinned": false,
-// "pinned_version": null
-// }
-// ],
-// "casks": []
-// }
+// {
+// "formulae": [
+// {
+// "name": "circleci",
+// "installed_versions": [
+// "0.1.1248"
+// ],
+// "current_version": "0.1.3923",
+// "pinned": false,
+// "pinned_version": null
+// }
+// ],
+// "casks": []
+// }
type HomebrewOutdated struct {
Formulae []struct {
Name string `json:"name"`