diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3d6f1ba --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Who's the owner of this repository? +* @nieomylnieja diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..7735e8a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,24 @@ +--- +name: Bug report +about: Create a bug report to help us solve the issue. +title: "[BUG]" +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior, ideally a minimal working working example. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**System details (please complete the following information):** + - OS (with version): [e.g. OS X 14.2.1, Ubuntu 22.04] + - `sloctl` version [e.g. v1.0.2] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..90be0c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Nobl9 Support + url: https://www.nobl9.com/contact/support + about: If you need help related to the whole Nobl9 platform or want to problem to remain private, contact us here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..760e93c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature request +about: Suggest an idea to help us improve the sloctl. +title: "[FEAT]" +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. +If you're willing to contribute with a PR of your own, let us know! + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2fdfd88 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## Motivation + +Describe what is the motivation behind the proposed changes. If possible reference the current solution/state of affairs. + +## Summary + +Recap of changed code. + +## Related changes + +List related changes from other PRs (if any). + +## Testing + +- Describe how to check introduced code changes manually. Provide example invocations and applied YAML configs. +- Take care of test coverage on unit and end-to-end levels. + +## Checklist + +- [ ] Include this change in Release Notes? + - If yes, write 1-3 sentences about the changes here and explicitly list all changes that can surprise our users. +- [ ] Are these changes required to be in sync with the API? Example of such can be extending adding support of new API. +It won't be usable until Nobl9 platform version is rolled out which exposes this API. + - If yes, you **MUST NOT** create an official release, instead, use a pre-release version, like `v1.1.0-rc1`. + - If the changes are independent of Nobl9 platform version, you can release an offical version, like `v1.1.0`. diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000..a1d3519 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,67 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "schedule:nonOfficeHours", // https://docs.renovatebot.com/presets-schedule/#schedulenonofficehours + ":enableVulnerabilityAlertsWithLabel(security)", // https://docs.renovatebot.com/presets-default/#enablevulnerabilityalertswithlabelarg0 + "group:recommended", // https://docs.renovatebot.com/presets-group/#grouprecommended + "workarounds:all", // https://docs.renovatebot.com/presets-workarounds/#workaroundsall + ], + "reviewersFromCodeOwners": true, + "dependencyDashboard": true, + "semanticCommits": "disabled", + "labels": ["dependencies", "renovate"], + "prHourlyLimit": 1, + "prConcurrentLimit": 5, + "rebaseWhen": "conflicted", + "rangeStrategy": "pin", + "branchPrefix": "renovate_", + // Auto-merge section. + // Only + "automergeType": "pr", + "lockFileMaintenance": { + "automerge": true + }, + "minor": { + "automerge": true + }, + "patch": { + "automerge": true + }, + "pin": { + "automerge": true + }, + // This will run go mod tidy after each go.mod update. + "postUpdateOptions": ["gomodTidy"], + // Groups: + "packageRules": [ + { + "matchManagers": ["gomod"], + "matchUpdateTypes": [ + "minor", + "patch", + ], + "groupName": "minor and patch golang dependencies", + }, + { + "matchManagers": ["github-actions"], + "addLabels": ["github-actions"], + }, + { + "matchManagers": ["gomod"], + "addLabels": ["golang"], + }, + { + "matchManagers": ["npm"], + "addLabels": ["javascript"], + }, + ], + // Custom version extraction from Makefile. + "regexManagers": [ + { + "fileMatch": ["^Makefile$"], + "matchStrings": [ + ".*renovate datasource=(?.*?) depName=(?.*?)\\n.*?_VERSION\\s?:=\\s?(?.*)\\s" + ] + } + ], +} diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..cb37e5d --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,44 @@ +name: Checks +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + check: + name: Run all checks for static analysis + runs-on: ubuntu-latest + env: + GO111MODULE: on + steps: + - name: Check out code + uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - name: Set up prerequisites - node and yarn + uses: actions/setup-node@v3 + - name: Set up yarn cache + id: yarn-cache + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v3 + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: Run spell and markdown checkers + run: make check/spell check/trailing check/markdown + - name: Check generated code + run: make check/generate + - name: Check formatting + run: make check/format + - name: Run go vet + run: make check/vet + - name: Run golangci-lint + run: make check/lint + - name: Run Gosec Security Scanner + run: make check/gosec diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..5009c96 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,21 @@ +name: End-to-end tests +on: + push: + tags: + - "v*" +jobs: + test: + name: Run e2e tests + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + - name: Run tests + run: make test/e2e + env: + SLOCTL_CLIENT_ID: "${{ secrets.SLOCTL_CLIENT_ID }}" + SLOCTL_CLIENT_SECRET: "${{ secrets.SLOCTL_CLIENT_ID }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..334d781 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,53 @@ +name: Release +on: + push: + tags: + - "^v[0-9]+.[0-9]+.[0-9]+$" +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout Source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: nobl9/sloctl + flavor: | + latest=true + tags: | + type=sha + type=semver,pattern={{version}} + - name: Release Binaries + uses: goreleaser/goreleaser-action@v5 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }} + - name: Build and push + id: docker_build + uses: docker/build-push-action@v5 + with: + push: true + platforms: linux/amd64, linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: GO_VERSION='-s -w -X github.com/nobl9/sloctl/internal/sloctl.BuildVersion=${{ github.ref_name }}' diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..5b48fd2 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,21 @@ +name: Unit tests +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + test: + name: Run unit tests + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + - name: Run tests + run: make test/unit diff --git a/.github/workflows/vulns.yml b/.github/workflows/vulns.yml new file mode 100644 index 0000000..d2c3361 --- /dev/null +++ b/.github/workflows/vulns.yml @@ -0,0 +1,25 @@ +name: Vulnerabilities +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + # Run at 8:00 AM every weekday. + - cron: '0 8 * * 1-5' +jobs: + scan: + name: Run Golang vulnerability check + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Setup Golang + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - name: Run Golang Vulncheck + run: make check/vulns diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97fba9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test +dist/ + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Code generation binaries +bin/ + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# IDE +.idea/ +.vscode/ + +# JS +node_modules/ +yarn.lock +yarn-error.log diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..263651d --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,98 @@ +run: + timeout: 5m + modules-download-mode: readonly + skip-dirs: + - scripts + skip-dirs-use-default: true + +issues: + # Enable all checks (which was as default disabled e.g. comments). + exclude-use-default: false + exclude-rules: + - linters: + - revive + text: exported (function|method|type) .*? should have comment or be unexported + - linters: + - revive + text: exported (const|var) .*? should have comment (\(or a comment on this block\) )?or be unexported + - linters: + - revive + text: "if-return: redundant if ...; err != nil check, just return error instead." + - linters: + - revive + text: "^var-naming: .*" + - linters: + - revive + text: "error-strings: error strings should not be capitalized or end with punctuation or a newline" + # Value 0 means show all. + max-issues-per-linter: 0 + max-same-issues: 0 + +linters-settings: + goimports: + # Put imports beginning with prefix after 3rd-party packages; + # it's a comma-separated list of prefixes. + local-prefixes: github.com/nobl9/nobl9-go + govet: + # False positives and reporting on error shadowing (which is intended). + # Quoting Robi Pike: + # The shadow code is marked experimental. + # It has too many false positives to be enabled by default, so this is not entirely unexpected, + # but don't expect a fix soon. The right way to detect shadowing without flow analysis is elusive. + # Few years later (comment from 2015) and the Shadow analyer is still experimental... + check-shadowing: false + lll: + line-length: 120 + gocritic: + enabled-tags: + - opinionated + disabled-checks: + - singleCaseSwitch + exhaustive: + # In switch statement treat label default: as being exhaustive. + default-signifies-exhaustive: true + misspell: + locale: US + gocognit: + min-complexity: 40 + +linters: + disable-all: true + enable: + # All linters from list https://golangci-lint.run/usage/linters/ are speciefed here and explicit enable/disable. + - asciicheck + - bodyclose + - dogsled + - errcheck + - exhaustive + - exportloopref + - gochecknoinits + - gocognit + - gocritic + - gocyclo + - gofmt + - goheader + - goimports + - goprintffuncname + - gosimple + - govet + - importas + - ineffassign + - lll + - makezero + - misspell + - nakedret + - nilerr + - prealloc + - predeclared + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - tparallel + - typecheck + - unconvert + - unparam + - unused + - wastedassign + - whitespace diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..4656388 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,59 @@ +project_name: sloctl + +builds: +- main: ./cmd/sloctl + env: + - CGO_ENABLED=0 + mod_timestamp: '{{ .CommitTimestamp }}' + flags: + - -trimpath + ldflags: + - '-s -w -X github.com/nobl9/sloctl/internal/sloctl.BuildVersion={{ .Version }} -X github.com/nobl9/sloctl/internal/sloctl.BuildGitBranch={{ .Branch }} -X github.com/nobl9/sloctl/internal/sloctl.BuildGitRevision={{ .ShortCommit }}' + goos: + - windows + - linux + - darwin + goarch: + - amd64 + binary: '{{ .ProjectName }}' + +archives: +- format: binary + name_template: '{{ .ProjectName }}-{{ if eq .Os "darwin" }}macos{{ else }}{{ .Os }}{{ end }}-{{ .Version }}' + +checksum: + name_template: '{{ .ProjectName }}-{{ .Version }}-SHA256SUMS' + algorithm: sha256 + +release: + github: + owner: nobl9 + name: sloctl + +brews: + - name: '{{ .ProjectName }}' + tap: + owner: nobl9 + name: 'homebrew-{{ .ProjectName }}' + branch: main + commit_msg_template: 'Brew formula update for {{ .ProjectName }} version {{ .Version }}' + homepage: https://docs.nobl9.com/sloctl-user-guide + description: Command-line client for Nobl9 + commit_author: + name: nobl9 + email: support@nobl9.com + test: | + assert_predicate bin/"{{ .ProjectName }}", :exist? + system "{{ .ProjectName }}", "--help" + install: | + bin.install Dir['{{ .ProjectName }}-*-{{ .Version }}'].first() => "{{ .ProjectName }}" + caveats: | + Thank you for installing the command-line client for Nobl9! + + To see help and a list of available commands type: + $ {{ .ProjectName }} help + + For more information on how to use the command-line client + and the Nobl9 managed cloud service, visit: + https://docs.nobl9.com/sloctl-user-guide + folder: Formula diff --git a/Dockerfile b/Dockerfile index ec59fcf..5e15811 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,20 @@ -FROM curlimages/curl:latest AS builder -ARG VERSION -RUN curl -sL https://github.com/nobl9/sloctl/releases/download/$VERSION/sloctl-linux-${VERSION/v/} -o /tmp/sloctl -RUN chmod +x /tmp/sloctl +FROM golang:1.21-alpine3.18 AS builder + +WORKDIR /app + +COPY ./go.mod ./go.sum ./ +COPY ./cmd/sloctl ./cmd/sloctl +COPY ./internal ./internal + +ARG LDFLAGS + +RUN CGO_ENABLED=0 go build \ + -ldflags "${LDFLAGS}" \ + -o /artifacts/sloctl \ + "${PWD}/cmd/sloctl" FROM scratch -COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -COPY --from=builder /tmp/sloctl /usr/bin/ -ENTRYPOINT ["sloctl"] \ No newline at end of file + +COPY --from=builder /artifacts/sloctl /usr/bin/sloctl + +ENTRYPOINT ["sloctl"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..10efc16 --- /dev/null +++ b/Makefile @@ -0,0 +1,202 @@ +.DEFAULT_GOAL := help +MAKEFLAGS += --silent --no-print-directory + +BIN_DIR := ./bin +TEST_DIR := ./test +APP_NAME := sloctl +LDFLAGS += -s -w +VERSION_PKG := "$(shell go list -m)/internal" + +ifndef BRANCH + BRANCH := $(shell git rev-parse --abbrev-ref HEAD) +endif +ifndef REVISION + REVISION := $(shell git rev-parse --short=8 HEAD) +endif + +# renovate datasource=github-releases depName=securego/gosec +GOSEC_VERSION := v2.18.2 +# renovate datasource=github-releases depName=golangci/golangci-lint +GOLANGCI_LINT_VERSION := v1.55.2 +# renovate datasource=go depName=golang.org/x/vuln/cmd/govulncheck +GOVULNCHECK_VERSION := v1.0.1 +# renovate datasource=go depName=golang.org/x/tools/cmd/goimports +GOIMPORTS_VERSION := v0.16.1 + +# Check if the program is present in $PATH and install otherwise. +# ${1} - oneOf{binary,yarn} +# ${2} - program name +define _ensure_installed + LOCAL_BIN_DIR=$(BIN_DIR) ./scripts/ensure_installed.sh "${1}" "${2}" +endef + +# Install Go binary using 'go install' with an output directory set via $GOBIN. +# ${1} - repository url +define _install_go_binary + GOBIN=$(realpath $(BIN_DIR)) go install "${1}" +endef + +# Print Makefile target step description for check. +# Only print top level steps this way, and not dependent steps, like 'install'. +# ${1} - step description +define _print_step + printf -- '------\n%s...\n' "${1}" +endef + +.PHONY: build +build: + go build -ldflags=$(LDFLAGS) -o $(BIN_DIR)/$(APP_NAME) . + +.PHONY: test/unit test/go/unit test/bats/% +## Run all unit tests. +test/unit: test/go/unit test/bats/unit + +.PHONY: test/e2e +## Run all e2e tests. +test/e2e: test/bats/e2e + +## Run go unit tests. +test/go/unit: + $(call _print_step,Running go unit tests) + go test -race -cover ./... + +## Run bats unit tests. +test/bats/unit: + $(call _print_step,Running bats unit tests) + docker build \ + --build-arg LDFLAGS="-X $(VERSION_PKG).BuildVersion=v1.0.0 -X $(VERSION_PKG).BuildGitBranch=PC-123-test -X $(VERSION_PKG).BuildGitRevision=e2602ddc" \ + -t sloctl-unit-test-bin . ; \ + docker build -t sloctl-bats-unit -f $(TEST_DIR)/Dockerfile.unit . + docker run -e TERM=linux --rm \ + sloctl-bats-unit -F pretty --filter-tags unit $(TEST_DIR)/* + +## Run bats unit tests. +test/bats/e2e: + echo "$(SLOCTL_CLIENT_ID)" + $(call _print_step,Running bats e2e tests) + docker build \ + --build-arg LDFLAGS="-X $(VERSION_PKG).BuildVersion=$(VERSION) -X $(VERSION_PKG).BuildGitBranch=$(BRANCH) -X $(VERSION_PKG).BuildGitRevision=$(REVISION)" \ + -t sloctl-e2e-test-bin . ; \ + docker build -t sloctl-bats-e2e -f $(TEST_DIR)/Dockerfile.e2e . + docker run --rm \ + -e SLOCTL_CLIENT_ID=$(SLOCTL_CLIENT_ID) \ + -e SLOCTL_CLIENT_SECRET=$(SLOCTL_CLIENT_SECRET) \ + -e SLOCTL_GIT_REVISION=$(REVISION) \ + sloctl-bats-e2e -F pretty --filter-tags e2e $(TEST_DIR)/* + +.PHONY: check check/vet check/lint check/gosec check/spell check/trailing check/markdown check/format check/generate check/vulns +## Run all checks. +check: check/vet check/lint check/gosec check/spell check/trailing check/markdown check/format check/generate check/vulns + +## Run 'go vet' on the whole project. +check/vet: + $(call _print_step,Running go vet) + go vet ./... + +## Run golangci-lint all-in-one linter with configuration defined inside .golangci.yml. +check/lint: + $(call _print_step,Running golangci-lint) + $(call _ensure_installed,binary,golangci-lint) + $(BIN_DIR)/golangci-lint run + +## Check for security problems using gosec, which inspects the Go code by scanning the AST. +check/gosec: + $(call _print_step,Running gosec) + $(call _ensure_installed,binary,gosec) + $(BIN_DIR)/gosec -exclude-generated -quiet ./... + +## Check spelling, rules are defined in cspell.json. +check/spell: + $(call _print_step,Verifying spelling) + $(call _ensure_installed,yarn,cspell) + yarn --silent cspell --no-progress '**/**' + +## Check for trailing whitespaces in any of the projects' files. +check/trailing: + $(call _print_step,Looking for trailing whitespaces) + yarn --silent check-trailing-whitespaces + +## Check markdown files for potential issues with markdownlint. +check/markdown: + $(call _print_step,Verifying Markdown files) + $(call _ensure_installed,yarn,markdownlint) + yarn --silent markdownlint '*.md' --disable MD010 # MD010 does not handle code blocks well. + +## Check for potential vulnerabilities across all Go dependencies. +check/vulns: + $(call _print_step,Running govulncheck) + $(call _ensure_installed,binary,govulncheck) + $(BIN_DIR)/govulncheck ./... + +## Verify if the auto generated code has been committed. +check/generate: + $(call _print_step,Checking if generated code matches the provided definitions) + ./scripts/check-generate.sh + +## Verify if the files are formatted. +## You must first commit the changes, otherwise it won't detect the diffs. +check/format: + $(call _print_step,Checking if files are formatted) + ./scripts/check-formatting.sh + +.PHONY: generate generate/code +## Auto generate files. +generate: generate/code + +## Generate Golang code. +generate/code: + echo "Generating Go code..." + go generate ./... + +.PHONY: format format/go format/cspell +## Format files. +format: format/go format/cspell + +## Format Go files. +format/go: + echo "Formatting Go files..." + $(call _ensure_installed,binary,goimports) + go fmt ./... + $(BIN_DIR)/goimports -local=github.com/nobl9/sloctl -w . + +## Format cspell config file. +format/cspell: + echo "Formatting cspell.yaml configuration (words list)..." + $(call _ensure_installed,yarn,yaml) + yarn --silent format-cspell-config + +.PHONY: install install/yarn install/golangci-lint install/gosec install/govulncheck install/goimports +## Install all dev dependencies. +install: install/yarn install/golangci-lint install/gosec install/govulncheck install/goimports + +## Install JS dependencies with yarn. +install/yarn: + echo "Installing yarn dependencies..." + yarn --silent install + +## Install golangci-lint (https://golangci-lint.run). +install/golangci-lint: + echo "Installing golangci-lint..." + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh |\ + sh -s -- -b $(BIN_DIR) $(GOLANGCI_LINT_VERSION) + +## Install gosec (https://github.com/securego/gosec). +install/gosec: + echo "Installing gosec..." + curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh |\ + sh -s -- -b $(BIN_DIR) $(GOSEC_VERSION) + +## Install govulncheck (https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck). +install/govulncheck: + echo "Installing govulncheck..." + $(call _install_go_binary,golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)) + +## Install goimports (https://pkg.go.dev/golang.org/x/tools/cmd/goimports). +install/goimports: + echo "Installing goimports..." + $(call _install_go_binary,golang.org/x/tools/cmd/goimports@$(GOIMPORTS_VERSION)) + +.PHONY: help +## Print this help message. +help: + ./scripts/makefile-help.awk $(MAKEFILE_LIST) diff --git a/README.md b/README.md index d52281b..81b0c36 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,59 @@ # sloctl -The binaries are available at [Releases](https://github.com/nobl9/sloctl/releases) page. +Sloctl is a command-line interface (CLI) for Nobl9. +You can use the sloctl CLI for creating or updating multiple SLOs and +objectives at once as part of CI/CD. -#### Build docker image locally +The web user interface is available to give you an easy way to create +and update SLOs and other resources, while sloctl aims to provide a +systematic and/or automated approach to maintaining SLOs as code. -1. Download Dockerfile and latest linux sloctl binary from the Releases page. Make sure they are in your working directory. -2. Build the image -```docker build -t .``` -3. Set environment variables if you plan to use them for authenticating with SLOCTL. Reference the [sloctl environment variables Doc](https://docs.nobl9.com/sloctl-user-guide/#configure-sloctl-with-environmental-variables). -4. Run the image +For more details check out +[sloctl user guide](https://docs.nobl9.com/sloctl-user-guide). + +## Install + +### Prebuilt Binaries + +The binaries are available at +[Releases](https://github.com/nobl9/sloctl/releases) page. + +### Go install + +```shell +go install github.com/nobl9/sloctl/cmd/sloctl@latest +``` + +### Homebrew + +```shell +brew tap nobl9/sloctl +brew install sloctl ``` -docker run --e SLOCTL_CLIENT_ID=$SLOCTL_CLIENT_ID \ --e SLOCTL_CLIENT_SECRET=$SLOCTL_CLIENT_SECRET \ - get slos --no-config-file -``` + +### Docker + +```shell +docker pull nobl9/sloctl +``` + +### Build Docker image locally + +1. Download Dockerfile and latest linux sloctl binary from the Releases page. + Make sure they are in your working directory. +2. Build the image: + + ```shell + docker build -t . + ``` + +3. Set environment variables if you plan to use them for authenticating with SLOCTL. + Reference the [sloctl environment variables Doc](https://docs.nobl9.com/sloctl-user-guide/#configure-sloctl-with-environmental-variables). +4. Run the image: + + ```shell + docker run + -e SLOCTL_CLIENT_ID=$SLOCTL_CLIENT_ID \ + -e SLOCTL_CLIENT_SECRET=$SLOCTL_CLIENT_SECRET \ + get slos --no-config-file + ``` diff --git a/bin/.keep b/bin/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/sloctl/main.go b/cmd/sloctl/main.go new file mode 100644 index 0000000..ed0f24a --- /dev/null +++ b/cmd/sloctl/main.go @@ -0,0 +1,12 @@ +// Sloctl provides a single binary command-line tool to interact with N9 application. +// It is heavily inspired by kubectl and follows its user experience and conventions. +// It uses the .config.toml configuration file and looks for it in $HOME/.config/nobl9. +// Path to this file can be overwritten by passing flag --config CONFIG_FILE_PATH +// example configuration file can be found in this repository samples/config.toml. +package main + +import "github.com/nobl9/sloctl/internal" + +func main() { + internal.Execute() +} diff --git a/codefresh.yml b/codefresh.yml deleted file mode 100644 index 443240f..0000000 --- a/codefresh.yml +++ /dev/null @@ -1,40 +0,0 @@ -version: "1.0" - -stages: - - "clone" - - "build" - -steps: - clone: - title: "Cloning repository" - type: "git-clone" - repo: "nobl9/sloctl" - # CF_BRANCH value is auto set when pipeline is triggered - # Learn more at codefresh.io/docs/docs/codefresh-yaml/variables/ - revision: "${{CF_BRANCH}}" - git: "github" - stage: "clone" - - version: - title: "Get sloctl version" - type: "freestyle" - image: 'alpine:3.17.2' - commands: - - apk add wget jq - - cf_export VERSION=$(wget -qO- https://api.github.com/repos/nobl9/sloctl/releases/latest | jq -r '.tag_name') - stage: "build" - - build: - title: "Building docker image" - type: "build" - working_directory: "${{clone}}" - arguments: - image_name: "nobl9/sloctl" - dockerfile: 'Dockerfile' - registry: "nobl9-n9release" - tag: "${{VERSION}}" - build_arguments: - - VERSION=${{VERSION}} - tags: - - "latest" - stage: "build" diff --git a/cspell.yaml b/cspell.yaml new file mode 100644 index 0000000..c138131 --- /dev/null +++ b/cspell.yaml @@ -0,0 +1,47 @@ +allowCompoundWords: true +language: en_US +dictionaries: + - companies + - misc + - softwareTerms + - go + - bash + - typescript + - node + - npm +ignoreRegExpList: + - /\|\s+[T|G]{2,}\s+\|/g + - /https?:\/\/.*/g + - /github.*/g + - /nolint.*/g + - /nosec.*/g +ignorePaths: + - .golangci.yml + - "**/*.lock" + - "**/go.mod" + - "**/go.sum" + - "**/node_modules/**" + - cspell.json + - CODEOWNERS + - bin/** + - test/** + - "**/test_data/**" + - dist/** +words: + - dynatrace + - endef + - gobin + - goimports + - golangci + - gosec + - govulncheck + - ldflags + - nobl + - plantuml + - sloctl + - slos + - svcs + - tpng + - vuln + - vulns + - wrapf diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fcced5c --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module github.com/nobl9/sloctl + +go 1.21 + +require ( + github.com/go-playground/validator/v10 v10.16.0 + github.com/goccy/go-yaml v1.11.2 + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db + github.com/nobl9/nobl9-go v0.77.2 + github.com/pkg/errors v0.9.1 + github.com/schollz/progressbar/v3 v3.14.1 + github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.8.4 + golang.org/x/sync v0.6.0 +) + +require ( + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/aws/aws-sdk-go v1.49.16 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx v1.2.27 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/rs/zerolog v1.31.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8e44a1d --- /dev/null +++ b/go.sum @@ -0,0 +1,170 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/aws/aws-sdk-go v1.49.16 h1:KAQwhLg296hfffRdh+itA9p7Nx/3cXS/qOa3uF9ssig= +github.com/aws/aws-sdk-go v1.49.16/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= +github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.11.2 h1:joq77SxuyIs9zzxEjgyLBugMQ9NEgTWxXfz2wVqwAaQ= +github.com/goccy/go-yaml v1.11.2/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= +github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx v1.2.27 h1:cvnTnda/YzdyFuWdEAMkI6BsLtItSrASEVCI3C/IUEQ= +github.com/lestrrat-go/jwx v1.2.27/go.mod h1:Stob9LjSqR3lOmNdxF0/TvZo60V3hUGv8Fr7Bwzla3k= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nobl9/nobl9-go v0.77.2 h1:EiUNXfc2QECaGZ8jisYdREk8WVksavOlaP7mNfz+IW4= +github.com/nobl9/nobl9-go v0.77.2/go.mod h1:baz6btl+crjNVUPpJB5FPosQJvkh0L3AdNXWiALdj0E= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= +github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/progressbar/v3 v3.14.1 h1:VD+MJPCr4s3wdhTc7OEJ/Z3dAeBzJ7yKH/P4lC5yRTI= +github.com/schollz/progressbar/v3 v3.14.1/go.mod h1:Zc9xXneTzWXF81TGoqL71u0sBPjULtEHYtj/WVgVy8E= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +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/stretchr/objx v0.1.0/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.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM= +golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= diff --git a/internal/apply.go b/internal/apply.go new file mode 100644 index 0000000..833131c --- /dev/null +++ b/internal/apply.go @@ -0,0 +1,130 @@ +package internal + +import ( + _ "embed" + "fmt" + + "github.com/spf13/cobra" + + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/sdk" +) + +type ApplyCmd struct { + client *sdk.Client + projectFlagWasSet bool + definitionPaths []string + dryRun bool + autoConfirm bool + replay bool + replayFrom TimeValue +} + +//go:embed apply_example.sh +var applyExample string + +// NewApplyCmd returns cobra command apply with all its flags. +func (r *RootCmd) NewApplyCmd() *cobra.Command { + apply := &ApplyCmd{} + + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply object definition in YAML or JSON format", + Long: getApplyOrDeleteDescription( + "The apply command commits the changes by sending the updates to the application."), + Example: applyExample, + Args: positionalArgsCondition, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + apply.client = r.GetClient() + if r.Flags.Project != "" { + apply.projectFlagWasSet = true + } + if apply.dryRun { + NotifyDryRunFlag() + } + }, + RunE: func(cmd *cobra.Command, args []string) error { return apply.Run(cmd) }, + } + + RegisterFileFlag(cmd, true, &apply.definitionPaths) + RegisterDryRunFlag(cmd, &apply.dryRun) + RegisterAutoConfirmationFlag(cmd, &apply.autoConfirm) + + const ( + replayFlagName = "replay" + replayFromFlagName = "from" + ) + cmd.Flags().BoolVar(&apply.replay, replayFlagName, false, + "Run Replay for the applied SLOs. If Replay fails, the applied changes are not rolled back.") + cmd.Flags().Var(&apply.replayFrom, replayFromFlagName, "Sets the start of Replay time window.") + cmd.MarkFlagsRequiredTogether(replayFlagName, replayFromFlagName) + + return cmd +} + +func (a ApplyCmd) Run(cmd *cobra.Command) error { + if a.dryRun { + a.client.WithDryRun() + } + if len(a.definitionPaths) == 0 { + return cmd.Usage() + } + objects, err := readObjectsDefinitions( + a.client.Config, + cmd, + a.definitionPaths, + newFilesPrompt(a.client.Config.FilesPromptEnabled, a.autoConfirm, a.client.Config.FilesPromptThreshold), + a.projectFlagWasSet) + if err != nil { + return err + } + printSourcesDetails("Applying", objects) + if err = a.client.Objects().V1().Apply(cmd.Context(), objects); err != nil { + return err + } + printCommandResult("The resources were successfully applied.", a.dryRun) + if a.replay { + return a.runReplay(cmd, objects) + } + return nil +} + +func (a ApplyCmd) runReplay(cmd *cobra.Command, objects []manifest.Object) error { + slos := filterByKind(objects, manifest.KindSLO) + if a.dryRun { + fmt.Printf("Skipping Replay. Found %d SLOs eligible for data import. (dry run)\n", len(slos)) + return nil + } + if len(slos) == 0 { + fmt.Println("Skipping Replay. No SLOs were found in the applied resources.") + return nil + } + replayCmd := ReplayCmd{client: a.client} + replays := make([]ReplayConfig, 0, len(slos)) + for _, slo := range slos { + replays = append(replays, ReplayConfig{ + Project: slo.GetProject(), + SLO: slo.GetName(), + From: a.replayFrom.Time, + }) + } + failedReplays, err := replayCmd.RunReplays(cmd, replays) + if err != nil || failedReplays > 0 { + fmt.Println("Warning! Applied changes are not rolled back when Replay fails." + + " Once you've fixed all related issues, we recommend using the 'sloctl replay' command" + + " to run Replay, or reapply the resources with the '--replay' flag.") + } + return err +} + +func filterByKind(objects []manifest.Object, kind manifest.Kind) []v1alpha.GenericObject { + var filtered []v1alpha.GenericObject + for i := range objects { + v, ok := objects[i].(v1alpha.GenericObject) + if ok && v.GetKind() == kind { + filtered = append(filtered, v) + } + } + return filtered +} diff --git a/internal/apply_example.sh b/internal/apply_example.sh new file mode 100644 index 0000000..9f780a3 --- /dev/null +++ b/internal/apply_example.sh @@ -0,0 +1,20 @@ +# Apply the configuration from slo.yaml. +sloctl apply -f ./slo.yaml + +# Apply resources from multiple different sources at once. +sloctl apply -f ./slo.yaml -f test/config.yaml -f https://nobl9.com/slo.yaml + +# Apply the YAML or JSON passed directly into stdin. +cat slo.yaml | sloctl apply -f - + +# Apply the configuration from slo.yaml and set project if it is not defined in file. +sloctl apply -f ./slo.yaml -p slo + +# Apply the configurations from all the files located at cwd recursively. +sloctl apply -f '**' + +# Apply the configurations from files with 'annotations' name within the whole directory tree. +sloctl apply -f '**/annotations*' + +# Apply the SLO(s) from slo.yaml and import its/their data from 2023-03-02T15:00:00Z until now. +sloctl apply -f ./slo.yaml --replay --from=2023-03-02T15:00:00Z diff --git a/internal/apply_or_delete_description.tpl b/internal/apply_or_delete_description.tpl new file mode 100644 index 0000000..f1e175a --- /dev/null +++ b/internal/apply_or_delete_description.tpl @@ -0,0 +1,6 @@ +{{ .Description }} +Sloctl supports glob patterns when using '-f' flag, it uses the standard Go glob patterns grammar and extends it with support of '**' for recursive reading of files and directories. +The standard Go grammar can be found here: https://pkg.go.dev/path/filepath#Match. +Only files with extensions: {{ .Extensions }} are processed when using glob patterns. +Additionally, before processing the file contents, sloctl checks if it contains Nobl9 API version with the following regex: '{{ .Regex }}'. +Remember that glob patterns must be quoted to prevent the shell from evaluating them. diff --git a/internal/aws_iam_ids.go b/internal/aws_iam_ids.go new file mode 100644 index 0000000..f18de19 --- /dev/null +++ b/internal/aws_iam_ids.go @@ -0,0 +1,96 @@ +package internal + +import ( + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/nobl9/nobl9-go/sdk" + + "github.com/nobl9/sloctl/internal/printer" +) + +type AwsIamIdsCmd struct { + client *sdk.Client + fieldSeparator string + recordSeparator string + outputFormat string + resourceName string +} + +func (r *RootCmd) NewAwsIamIds() *cobra.Command { + awsIamIds := &AwsIamIdsCmd{} + + cobraCmd := &cobra.Command{ + Use: "aws-iam-ids", + Short: "Returns IAM IDs used in AWS integrations", + } + + direct := &cobra.Command{ + Use: "direct [direct-name]", + Short: "Returns external ID and AWS account ID for given direct name", + Long: "Returns external ID and AWS account ID that can be used to create cross-account IAM roles." + + "\nMore details available at: https://docs.nobl9.com/Sources/Amazon_CloudWatch/#cross-account-iam-roles-new.", + Args: awsIamIds.arguments, + PersistentPreRun: func(iamIdsCmd *cobra.Command, args []string) { awsIamIds.client = r.GetClient() }, + RunE: func(iamIdsCmd *cobra.Command, args []string) error { return awsIamIds.Direct(iamIdsCmd) }, + } + RegisterOutputFormatFlags(direct, &awsIamIds.outputFormat, &awsIamIds.fieldSeparator, &awsIamIds.recordSeparator) + cobraCmd.AddCommand(direct) + + dataExport := &cobra.Command{ + Use: "dataexport", + Short: "Returns AWS external ID, which will be used by Nobl9 to assume the IAM role when" + + " performing data export", + PersistentPreRun: func(iamIdsCmd *cobra.Command, args []string) { awsIamIds.client = r.GetClient() }, + RunE: func(iamIdsCmd *cobra.Command, args []string) error { return awsIamIds.DataExport(iamIdsCmd) }, + } + cobraCmd.AddCommand(dataExport) + + return cobraCmd +} + +func (a *AwsIamIdsCmd) arguments(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + _ = cmd.Usage() + if len(args) == 0 { + return errors.New("Direct name must be provided") + } + return errors.New("command expects a single argument, Direct name") + } + a.resourceName = args[0] + return nil +} + +func (a *AwsIamIdsCmd) Direct(cmd *cobra.Command) error { + ctx := cmd.Context() + response, err := a.client.AuthData().V1().GetDirectIAMRoleIDs(ctx, a.client.Config.Project, a.resourceName) + if err != nil { + return errors.Wrap(err, "unable to get AWS IAM role auth external IDs") + } + + p, err := printer.New(cmd.OutOrStdout(), printer.Format(a.outputFormat), a.fieldSeparator, a.recordSeparator) + if err != nil { + return err + } + if err = p.Print(response); err != nil { + return err + } + return nil +} + +func (a *AwsIamIdsCmd) DataExport(cmd *cobra.Command) error { + ctx := cmd.Context() + response, err := a.client.AuthData().V1().GetDataExportIAMRoleIDs(ctx) + if err != nil { + return errors.Wrap(err, "unable to get AWS external ID") + } + + p, err := printer.New(cmd.OutOrStdout(), printer.Format(a.outputFormat), a.fieldSeparator, a.recordSeparator) + if err != nil { + return err + } + if err = p.Print(response.ExternalID); err != nil { + return err + } + return nil +} diff --git a/internal/common.go b/internal/common.go new file mode 100644 index 0000000..0387f4f --- /dev/null +++ b/internal/common.go @@ -0,0 +1,102 @@ +package internal + +import ( + _ "embed" + "fmt" + "sort" + "strings" + "text/template" + + "github.com/spf13/cobra" + + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/sdk" +) + +// ref: https://github.com/spf13/cobra/issues/1466 +// Ways to prevent shell glob expansion: +// +// - quote it: +// `$ sloctl apply -f '*'` +// +// - escape it: +// `$ sloctl apply -f \*` +// +// - disable the glob expansion +// `$ set -f` +// or +// `$ set -o noglob` +func positionalArgsCondition(_ *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + return fmt.Errorf("command accepts 0 args, received %d, make sure you're quoting"+ + " the glob pattern to prevent shell from doing it for you", len(args)) +} + +func printSourcesDetails(verb string, objects []manifest.Object) { + var b strings.Builder + b.WriteString(fmt.Sprintf("%s %d objects from the following sources: \n", verb, len(objects))) + uniq := make(map[string]struct{}, len(objects)/2) // Rough estimation of the objects from provided sources. + sort.SliceStable(objects, func(i, j int) bool { + return objects[j].GetManifestSource() > objects[i].GetManifestSource() + }) + for i := range objects { + src := objects[i].GetManifestSource() + if _, ok := uniq[src]; ok { + continue + } + uniq[src] = struct{}{} + b.WriteString(" - ") + b.WriteString(src) + b.WriteString("\n") + } + _, isStdin := uniq["stdin"] + if len(uniq) == 1 && isStdin { + return + } + fmt.Print(b.String()) +} + +func printCommandResult(message string, dryRun bool) { + if dryRun { + message += " (dry run)" + } + fmt.Println(message) +} + +//go:embed apply_or_delete_description.tpl +var applyOrDeleteDescription string + +func getApplyOrDeleteDescription(description string) string { + tpl, err := template.New("applyOrDeleteDescription").Parse(applyOrDeleteDescription) + if err != nil { + panic(err) + } + extensionsBuilder := strings.Builder{} + extensions := sdk.GetSupportedFileExtensions() + for i, ext := range extensions { + extensionsBuilder.WriteString("'" + ext + "'") + if i == len(extensions)-1 { + break + } + if i == len(extensions)-2 { + extensionsBuilder.WriteString(" and ") + continue + } + extensionsBuilder.WriteString(", ") + } + var b strings.Builder + if err = tpl.Execute(&b, struct { + Description string + Extensions string + Regex string + }{ + Description: description, + Extensions: extensionsBuilder.String(), + Regex: sdk.APIVersionRegex, + }); err != nil { + panic(err) + } + return b.String() +} diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..c45a8a2 --- /dev/null +++ b/internal/config.go @@ -0,0 +1,472 @@ +package internal + +import ( + "bufio" + "fmt" + "os" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/nobl9/nobl9-go/sdk" +) + +const defaultProject = "default" + +var ( + errWrongRenameSyntax = fmt.Errorf(`command "rename-context" requires exactly two arguments with names of your contexts +Example: sloctl config rename-context [oldContext] [newContext]`) + + errWrongDeleteSyntax = fmt.Errorf(`command "delete-context" requires exactly one argument with context name +Example: sloctl config delete-context [contextName]`) +) + +type ConfigCmd struct { + config *sdk.FileConfig + verbose bool +} + +func (r *RootCmd) NewConfigCmd() *cobra.Command { + configCmd := ConfigCmd{} + cmd := &cobra.Command{ + Use: "config", + Short: "Configuration management", + Long: `Manage configurations stored in configuration file.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + fileConfig := new(sdk.FileConfig) + configPath := r.Flags.ConfigFile + if configPath == "" { + var err error + configPath, err = sdk.GetDefaultConfigPath() + if err != nil { + return err + } + } + if err := fileConfig.Load(configPath); err != nil { + return err + } + configCmd.config = fileConfig + return nil + }, + } + + cmd.AddCommand(configCmd.AddContextCommand()) + cmd.AddCommand(configCmd.CurrentContextCommand()) + cmd.AddCommand(configCmd.GetContextsCommand()) + cmd.AddCommand(configCmd.RenameContextCommand()) + cmd.AddCommand(configCmd.DeleteContextCommand()) + cmd.AddCommand(configCmd.SetDefaultContextCommand()) + + return cmd +} + +// AddContextCommand returns cobra command add-context, allows to add context to your configuration file. +func (c *ConfigCmd) AddContextCommand() *cobra.Command { + return &cobra.Command{ + Use: "add-context", + Short: "Add new sloctl configuration context", + Long: "Add new sloctl configuration context, an interactive command which collects parameters in wizard mode.", + RunE: func(cmd *cobra.Command, args []string) error { + if c.config.Contexts == nil { + c.config.Contexts = make(map[string]sdk.ContextConfig) + } + + scanner := bufio.NewScanner(os.Stdin) + newConfigContext, contextName, scanningStop, err := scanContext(c.config, scanner) + if scanningStop || err != nil { + return err + } + + ok, err := scanParams(&newConfigContext, scanner) + if !ok { + if err == nil { + fmt.Println() + } + return err + } + + if c.config.DefaultContext != contextName { + fmt.Printf("Set \"%s\" as a default context? [y/N]: ", contextName) + if !scanner.Scan() { + err = scanner.Err() + if err == nil { + fmt.Println() + } + return nil + } + newContextName := scanDefaultContext(contextName, scanner) + if newContextName != "" { + c.config.DefaultContext = newContextName + } + } + + c.config.Contexts[contextName] = newConfigContext + + return c.config.Save(c.config.GetPath()) + }, + } +} + +func scanContext(fileConfig *sdk.FileConfig, scanner *bufio.Scanner) ( + newConfigContext sdk.ContextConfig, + contextName string, scanStop bool, err error, +) { + newConfigContext = sdk.ContextConfig{} + fmt.Print("New context name: ") + if !scanner.Scan() { + err = scanner.Err() + if err == nil { + fmt.Println() + } + return newConfigContext, "", true, err + } + contextName = strings.ToLower(strings.TrimSpace(scanner.Text())) + isAllowedContextName := regexp.MustCompile(`^[a-zA-Z0-9\-]+$`).MatchString + if !isAllowedContextName(contextName) { + return newConfigContext, "", true, errors.New("Enter a valid context name." + + " Use letters, numbers and `-` characters.") + } + + if cc, ok := fileConfig.Contexts[contextName]; ok { + fmt.Printf( + "Context \"%s\" is already in the configuration file.\nDo you want to overwrite it? [y/N]: ", + contextName) + if !scanner.Scan() { + err = scanner.Err() + if err == nil { + fmt.Println() + } + return newConfigContext, contextName, true, err + } + yesNo := strings.ToLower(strings.TrimSpace(scanner.Text())) + if yesNo == "y" { + newConfigContext = cc + newConfigContext.AccessToken = "" + } else { + fmt.Println("Please try to add a new context with a different name.") + return newConfigContext, contextName, true, nil + } + } + return newConfigContext, contextName, false, nil +} + +func scanParams(config *sdk.ContextConfig, scanner *bufio.Scanner) (bool, error) { + var existingClientID string + if config.ClientID != "" { + existingClientID = fmt.Sprintf(" [%s]", credentialPreview(config.ClientID)) + } + fmt.Printf("Client ID%s: ", existingClientID) + if !scanner.Scan() { + return false, scanner.Err() + } + inputClientID := scanner.Text() + if inputClientID != "" { + config.ClientID = inputClientID + } + + var existingClientSecret string + if config.ClientSecret != "" { + existingClientSecret = fmt.Sprintf(" [%s]", credentialPreview(config.ClientSecret)) + } + fmt.Printf("Client Secret%s: ", existingClientSecret) + if !scanner.Scan() { + return false, scanner.Err() + } + inputClientSecret := scanner.Text() + if inputClientSecret != "" { + config.ClientSecret = inputClientSecret + } + + if config.Project == "" { + config.Project = defaultProject + } + + fmt.Printf("Project [%s]: ", config.Project) + if !scanner.Scan() { + return false, scanner.Err() + } + if inputProject := scanner.Text(); inputProject != "" { + config.Project = inputProject + } + + return true, nil +} + +func scanDefaultContext(contextName string, scanner *bufio.Scanner) string { + yesNo := scanner.Text() + yesNo = strings.ToLower(strings.TrimSpace(yesNo)) + if yesNo == "y" { + return contextName + } + return "" +} + +// SetDefaultContextCommand return cobra command to set current context in configuration file. +func (c *ConfigCmd) SetDefaultContextCommand() *cobra.Command { + return &cobra.Command{ + Use: "use-context [context name]", + Short: "Set the default context", + Long: "Set a default context in the existing config file.", + RunE: func(cmd *cobra.Command, args []string) error { + scanner := bufio.NewScanner(os.Stdin) + + if len(c.config.Contexts) == 0 { + return errors.New("You don't have any contexts in the current configuration file.\n" + + "Add at least one context in the current configuration file and then set it as the default.\n" + + "Run \"sloctl config add-context\" or indicate the path to the file using flag \"--config\".") + } + + var contextName string + if len(args) > 0 { + contextName = strings.TrimSpace(args[0]) + } else { + var names []string + for existContextName := range c.config.Contexts { + names = append(names, existContextName) + } + fmt.Printf("Select the default context from the existing contexts [%s]: ", strings.Join(names, ", ")) + if !scanner.Scan() { + return nil + } + contextName = scanner.Text() + contextName = strings.TrimSpace(contextName) + } + + if _, exist := c.config.Contexts[contextName]; !exist { + // nolint: revive + return errors.Errorf( + "there is no such context: \"%s\", please enter the correct name", + contextName) + } + c.config.DefaultContext = contextName + + if err := c.config.Save(c.config.GetPath()); err != nil { + return err + } + fmt.Printf("Switched to context \"%s\"\n", contextName) + return nil + }, + } +} + +func credentialPreview(val string) string { + const forcedLen = 20 + const disclosedEndingLen = 4 + const anonymousChar = "*" + const defaultIfEmpty = "None" + if val == "" { + return defaultIfEmpty + } + return anonymize(val, forcedLen, disclosedEndingLen, anonymousChar) +} + +func anonymize(val string, forcedLen, disclosedEndingLen int, anonymousChar string) string { + if len(val) < disclosedEndingLen { + disclosedEndingLen = len(val) + } + return fmt.Sprintf("%s%s", + strings.Repeat(anonymousChar, forcedLen-disclosedEndingLen), + val[len(val)-disclosedEndingLen:]) +} + +// CurrentContextCommand returns cobra command current-context, prints current used context. +func (c *ConfigCmd) CurrentContextCommand() *cobra.Command { + currentCtxCmd := &cobra.Command{ + Use: "current-context", + Short: "Display current context", + Long: "Display configuration for the current context set in the configuration file.", + RunE: func(cmd *cobra.Command, args []string) error { + if c.verbose { + currentContext := displayContext(c.config.DefaultContext, + c.config.Contexts[c.config.DefaultContext], + c.verbose) + fmt.Print(currentContext) + return nil + } + fmt.Println(c.config.DefaultContext) + return nil + }, + } + + RegisterVerboseFlag(currentCtxCmd, &c.verbose) + + return currentCtxCmd +} + +// GetContextsCommand returns cobra command to prints all available contexts. +func (c *ConfigCmd) GetContextsCommand() *cobra.Command { + getContextsCmd := &cobra.Command{ + Use: "get-contexts", + Short: "Display all available contexts", + Long: "Display all available contexts in the configuration file.", + RunE: func(cmd *cobra.Command, args []string) error { + var names []string + for name := range c.config.Contexts { + names = append(names, name) + } + sort.Strings(names) + var fullConfig string + if len(args) == 0 && c.verbose { + for _, name := range names { + singleConfig := displayContext(name, c.config.Contexts[name], true) + fullConfig += singleConfig + "\n" + } + fmt.Printf("[%s]\n%s", strings.Join(names, ", "), fullConfig) + return nil + } + for _, name := range args { + if _, ok := c.config.Contexts[name]; !ok { + fullConfig += fmt.Sprintf("Missing context: %s\n\n", name) + continue + } + singleConfig := displayContext(name, c.config.Contexts[name], true) + fullConfig += singleConfig + "\n" + } + fmt.Printf("[%s]\n%s", strings.Join(names, ", "), fullConfig) + return nil + }, + } + + RegisterVerboseFlag(getContextsCmd, &c.verbose) + + return getContextsCmd +} + +func displayContext(name string, config sdk.ContextConfig, verbose bool) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Context: %s\n", name)) + if !verbose { + return sb.String() + } + configuration := []struct { + Name string + Value string + }{ + {Name: "client ID", Value: config.ClientID}, + {Name: "client secret", Value: censorField(config.ClientSecret)}, + {Name: "project", Value: config.Project}, + {Name: "url", Value: config.URL}, + {Name: "oktaOrgURL", Value: config.OktaOrgURL}, + {Name: "oktaAuthServer", Value: config.OktaAuthServer}, + {Name: "disable okta", Value: func() string { + if config.DisableOkta != nil { + return strconv.FormatBool(*config.DisableOkta) + } + return "" + }()}, + {Name: "timeout", Value: func() string { + if config.Timeout != nil { + return config.Timeout.String() + } + return "" + }()}, + } + for _, field := range configuration { + if field.Value != "" { + sb.WriteString(fmt.Sprintf("\t%s: %s\n", field.Name, field.Value)) + } + } + return sb.String() +} + +func censorField(field string) (censored string) { + if len(field) > 3 { + return field[:2] + "***" + field[len(field)-2:] + } else if len(field) != 0 { + return generateMissingSecretMessage() + } + return censored +} + +// RenameContextCommand return cobra command to rename one of contexts in configuration file. +func (c *ConfigCmd) RenameContextCommand() *cobra.Command { + renameContextCmd := &cobra.Command{ + Use: "rename-context", + Short: "Rename chosen context", + Long: "Rename one of the contexts in the configuration file.", + Example: " sloctl config rename-context [oldContext] [newContext]", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 2 { + return errWrongRenameSyntax + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + oldContext, newContext := args[0], args[1] + if _, ok := c.config.Contexts[oldContext]; !ok { + return errors.Errorf("selected context \"%s\" doesn't exists", oldContext) + } + if _, ok := c.config.Contexts[newContext]; ok { + return errors.Errorf("selected context name \"%s\" is already in use", newContext) + } + + if c.config.DefaultContext == oldContext { + fmt.Printf("Selected context was set as default. Changing default context to %s.\n", newContext) + c.config.DefaultContext = newContext + } + + c.config.Contexts[newContext] = c.config.Contexts[oldContext] + delete(c.config.Contexts, oldContext) + + if err := c.config.Save(c.config.GetPath()); err != nil { + return err + } + fmt.Printf("Renaming: \"%s\" to \"%s\"\n", oldContext, newContext) + return nil + }, + } + + return renameContextCmd +} + +// DeleteContextCommand return cobra command to delete context from configuration file. +func (c *ConfigCmd) DeleteContextCommand() *cobra.Command { + delContextCmd := &cobra.Command{ + Use: "delete-context", + Short: "Delete chosen context", + Long: "Delete one of the contexts in the configuration file.", + Example: " sloctl config delete-context [context-name]", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errWrongDeleteSyntax + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + toDeleteCtx := args[0] + if _, ok := c.config.Contexts[toDeleteCtx]; !ok { + return errors.Errorf("selected context \"%s\" doesn't exists", toDeleteCtx) + } + + if toDeleteCtx == c.config.DefaultContext { + return errors.Errorf("cannot remove context currently set as default") + } + + delete(c.config.Contexts, toDeleteCtx) + + if err := c.config.Save(c.config.GetPath()); err != nil { + return err + } + fmt.Printf("Context \"%s\" has been deleted.\n", toDeleteCtx) + return nil + }, + } + + return delContextCmd +} + +func generateMissingSecretMessage() string { + secretMessages := map[string]struct{}{ + "who needs security anyway?": {}, + "this secret could be guessed by any PC in less than 0.01s": {}, + "I know it is easier to remember": {}, + } + for key := range secretMessages { + return key + } + return "" +} diff --git a/internal/csv/csv.go b/internal/csv/csv.go new file mode 100644 index 0000000..37fcdc2 --- /dev/null +++ b/internal/csv/csv.go @@ -0,0 +1,235 @@ +// Package csv provides a builder for csv data +package csv + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" +) + +const ( + headerConjunction = "." + DefaultFieldSeparator = "," + DefaultRecordSeparator = "\n" + FieldSeparatorFlag = "field-separator" + RecordSeparatorFlag = "record-separator" +) + +type Node struct { + root *Node + parent *Node + headLocal string + headFull string + tail interface{} + children []*Node + headToNodeMap map[string]*Node +} + +// NewCSVNode returns instance of preconfigured CSV Node. +// It contains the information needed to generate a header for data in corresponding CSV field. +func NewCSVNode( + root *Node, + parent *Node, + headLocal string, + headFull string, + tail interface{}, +) Node { + return Node{ + root: root, + parent: parent, + headLocal: headLocal, + headFull: headFull, + tail: tail, + } +} + +// NewCSVRoot returns instance of preconfigured CSV Node. +// The returned node is a root of the graph. +func NewCSVRoot( + tail interface{}, +) Node { + root := Node{ + tail: tail, + headToNodeMap: make(map[string]*Node), + } + root.root = &root + return root +} + +// Marshal marshals the object into JSON then converts JSON to CSV then returns the CSV. +func Marshal(input interface{}, fieldSeparator, recordSeparator string) ([]byte, error) { + type jsonRawOutput = map[string]interface{} + var outputItemsRaw []jsonRawOutput + + jsonRawInput, err := json.Marshal(input) + if err != nil { + return nil, err + } + + // Check if json is array, if not convert to array because unmarshal requires array + if jsonRawInput[0] != '[' { + jsonRawInput = append([]byte{'['}, jsonRawInput...) + jsonRawInput = append(jsonRawInput, ']') + } + + if err = json.Unmarshal(jsonRawInput, &outputItemsRaw); err != nil { + return nil, err + } + + nodes := make([]*Node, len(outputItemsRaw)) + + for i, tailRaw := range outputItemsRaw { + newNode := NewCSVRoot(tailRaw) + nodes[i] = &newNode + } + + for _, node := range nodes { + if err = node.ExpandTail(); err != nil { + return nil, err + } + } + + var leaves []*Node + for _, node := range nodes { + nodeLeaves := node.GetLeaves() + leaves = append(leaves, nodeLeaves...) + } + + var output strings.Builder + headers, err := getUniqueHeaders(leaves) + if err != nil { + return nil, err + } + output.WriteString(strings.Join(headers, fieldSeparator)) + output.WriteString(recordSeparator) + for _, node := range nodes { + recordTmp := "" + for index, header := range headers { + if index > 0 { + recordTmp += fieldSeparator + } + if node.headToNodeMap[header] != nil && node.headToNodeMap[header].tail != nil { + recordTmp += fmt.Sprintf("%v", node.headToNodeMap[header].tail) + } + } + output.WriteString(recordTmp) + output.WriteString(recordSeparator) + } + + return []byte(output.String()), nil +} + +// ExpandTail retrieves the values of all tails in the graph and creates the whole graph structure. +func (node *Node) ExpandTail() error { + switch nodeTail := node.tail.(type) { + case string: + escapedCsvInjectionSigns := escapeCSVInjectionSigns(nodeTail) + escapedDoubleQuotes := strings.ReplaceAll(escapedCsvInjectionSigns, `"`, `""`) + enclosedField := fmt.Sprintf("%s%s%s", `"`, escapedDoubleQuotes, `"`) + node.tail = enclosedField + case float64, bool: + node.tail = nodeTail + case []interface{}: + for childHeadInt, childTail := range nodeTail { + childHeadStr := strconv.Itoa(childHeadInt) + newHeadFull := generateFullHeader(node.headFull, headerConjunction, childHeadStr) + newNode := NewCSVNode(node.root, node, childHeadStr, newHeadFull, childTail) + node.children = append(node.children, &newNode) + if err := newNode.ExpandTail(); err != nil { + return err + } + } + case map[string]interface{}: + for childHead, childTail := range nodeTail { + newHeadFull := generateFullHeader(node.headFull, headerConjunction, childHead) + newNode := NewCSVNode(node.root, node, childHead, newHeadFull, childTail) + node.children = append(node.children, &newNode) + if err := newNode.ExpandTail(); err != nil { + return err + } + } + case nil: + node.tail = nil + default: + return fmt.Errorf("error expanding the tail of csv node - type mismatch: %v", node.tail) + } + return nil +} + +// GetLeaves returns all leaves from the sub-tree starting from the specific node. +func (node *Node) GetLeaves() []*Node { + var leaves []*Node + if len(node.children) == 0 { + node.root.headToNodeMap[node.headFull] = node + return []*Node{node} + } + for _, childNode := range node.children { + childLeaves := childNode.GetLeaves() + leaves = append(leaves, childLeaves...) + } + return leaves +} + +// GetHeaders returns an array of headers of all child nodes. +func (node *Node) GetHeaders() ([]string, error) { + var headersSet []string + if len(node.children) == 0 { + return []string{node.headFull}, nil + } + for _, childNode := range node.children { + childHeaders, err := childNode.GetHeaders() + if err != nil { + return nil, err + } + headersSet = append(headersSet, childHeaders...) + } + + return headersSet, nil +} + +func getUniqueHeaders(nodes []*Node) ([]string, error) { + var headersList []string + for _, node := range nodes { + nodeHeaders, err := node.GetHeaders() + if err != nil { + return nil, err + } + headersList = append(headersList, nodeHeaders...) + } + headersSet := removeDuplicates(headersList) + return headersSet, nil +} + +func removeDuplicates(values []string) []string { + keys := make(map[string]struct{}) + var uniqueValues []string + for _, value := range values { + if _, ok := keys[value]; !ok { + keys[value] = struct{}{} + uniqueValues = append(uniqueValues, value) + } + } + sort.Strings(uniqueValues) + return uniqueValues +} + +func generateFullHeader(prefix, conjunction, suffix string) string { + if prefix != "" { + return fmt.Sprintf("%s%s%s", prefix, conjunction, suffix) + } + return suffix +} + +func escapeCSVInjectionSigns(input string) string { + if strings.HasPrefix(input, "@") || + strings.HasPrefix(input, "=") || + strings.HasPrefix(input, "+") || + strings.HasPrefix(input, "-") || + strings.HasPrefix(input, "\x09") || + strings.HasPrefix(input, "\x0D") { + return fmt.Sprintf("'%s", input) + } + return input +} diff --git a/internal/definitions.go b/internal/definitions.go new file mode 100644 index 0000000..8e6d635 --- /dev/null +++ b/internal/definitions.go @@ -0,0 +1,122 @@ +package internal + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/sdk" +) + +const filesPromptPattern = "You're applying more than %d files (%d). Do you want to continue? (y/n): " + +// readObjectsDefinitions reads object definitions from the provided definition paths. +// Empty definition path or '-' are treated as input from os.Stdin. +func readObjectsDefinitions( + config *sdk.Config, + cmd *cobra.Command, + definitionPaths []string, + prompt filesPrompt, + projectFlagWasSet bool, +) ([]manifest.Object, error) { + containsStdin := false + for i := range definitionPaths { + if definitionPaths[i] == "" || definitionPaths[i] == "-" { + definitionPaths = append(definitionPaths[:i], definitionPaths[i+1:]...) + containsStdin = true + } + } + sources, err := sdk.ResolveObjectSources(definitionPaths...) + if err != nil { + return nil, err + } + if err = runPrompt(cmd, prompt, sources); err != nil { + return nil, err + } + if containsStdin { + sources = append(sources, sdk.NewObjectSourceReader(cmd.InOrStdin(), "stdin")) + } + defs, err := sdk.ReadObjectsFromSources(cmd.Context(), sources...) + if err != nil { + return nil, err + } + defs = manifest.SetDefaultProject(defs, config.Project) + if !projectFlagWasSet { + return defs, nil + } + // Make sure the --project flag matches all the parsed definitions projects. + for i := range defs { + obj, isProjectScoped := defs[i].(manifest.ProjectScopedObject) + // Since v1alpha.ObjectGeneric fulfills manifest.ProjectScopedObject + if !isProjectScoped || obj.GetProject() == "" { + continue + } + if obj.GetProject() != config.Project { + return nil, errors.Errorf( + "The %[1]s project from the provided object %[2]s.%[1]s does not match "+ + "the project '%[3]s'. You must pass '--project=%[1]s' to perform this operation or"+ + " allow the Project to be inferred from the object definition.", + obj.GetProject(), obj.GetName(), config.Project) + } + } + return defs, nil +} + +func runPrompt(cmd *cobra.Command, prompt filesPrompt, sources []*sdk.ObjectSource) error { + if !prompt.Enabled || prompt.AutoConfirm { + return nil + } + resolvedPathsCount := 0 + for i := range sources { + if sources[i].Type == sdk.ObjectSourceTypeDirectory || + sources[i].Type == sdk.ObjectSourceTypeGlobPattern { + resolvedPathsCount += len(sources[i].Paths) + } + } + if resolvedPathsCount > 0 && resolvedPathsCount > prompt.Threshold { + cmd.Printf(filesPromptPattern, prompt.Threshold, resolvedPathsCount) + return prompt.Prompt() + } + return nil +} + +func newFilesPrompt(enabled, autoConfirm bool, threshold int) filesPrompt { + return filesPrompt{ + Enabled: enabled, + AutoConfirm: autoConfirm, + Threshold: threshold, + ReadFrom: os.Stdin, + } +} + +type filesPrompt struct { + Enabled bool + AutoConfirm bool + Threshold int + ReadFrom io.Reader +} + +var errOperationAborted = errors.New("operation aborted") + +func (f filesPrompt) Prompt() error { + var choice string + if _, err := fmt.Fscanln(f.ReadFrom, &choice); err != nil { + // When a single '\n' is provided, fmt.ScanState.SkipSpace() + // implementation will return error "unexpected newline". + if errors.Is(err, io.EOF) || err.Error() == "unexpected newline" { + return errOperationAborted + } + return errors.Wrap(err, "failed to read confirmation from stdin") + } + switch strings.ToLower(choice) { + case "y", "yes": + return nil + default: + return errOperationAborted + } +} diff --git a/internal/definitions_test.go b/internal/definitions_test.go new file mode 100644 index 0000000..4180fb4 --- /dev/null +++ b/internal/definitions_test.go @@ -0,0 +1,180 @@ +//go:build unit_test + +package internal + +import ( + "bytes" + _ "embed" + "fmt" + "io" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nobl9/nobl9-go/sdk" +) + +//go:embed test_data/definitions_test/project.yaml +var testProject []byte + +//go:embed test_data/definitions_test/service.yaml +var testService []byte + +func TestReadResourceDefinitions(t *testing.T) { + for name, test := range map[string]struct { + Paths []string + In io.Reader + Prompt filesPrompt + PromptResponse string + ExpectedDefinitions int + PromptDisplayed bool + ExpectedError error + }{ + "read a single file": { + Paths: []string{"test_data/definitions_test/project.yaml"}, + In: bytes.NewBuffer(testProject), + ExpectedDefinitions: 1, + }, + "read from stdin via ' ' source": { + Paths: []string{""}, + In: bytes.NewBuffer(testService), + ExpectedDefinitions: 1, + }, + "read from stdin via '-' source": { + Paths: []string{"-"}, + In: bytes.NewBuffer(testService), + ExpectedDefinitions: 1, + }, + "read from stdin and a file": { + Paths: []string{"test_data/definitions_test/project.yaml", ""}, + In: bytes.NewBuffer(testService), + ExpectedDefinitions: 2, + }, + "don't display prompt if threshold is not reached": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 10}, + PromptDisplayed: false, + }, + "don't display prompt if threshold is exceeded, but prompt is disabled": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: false, Threshold: 1}, + PromptDisplayed: false, + }, + "don't display prompt if threshold is exceeded, but auto confirm is set": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, AutoConfirm: true, Threshold: 1}, + PromptDisplayed: false, + }, + "don't display prompt if multiple definitions.SourceTypeFile sources are provided exceeding threshold": { + Paths: []string{ + "test_data/definitions_test/project.yaml", + "test_data/definitions_test/service.yaml", + }, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 1}, + PromptDisplayed: false, + }, + "display prompt when threshold is exceeded (variant 1)": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 1}, + PromptResponse: "y\n", + PromptDisplayed: true, + }, + "display prompt when threshold is exceeded (variant 2)": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 1}, + PromptResponse: "yes\n", + PromptDisplayed: true, + }, + "display prompt when threshold is exceeded (variant 3)": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 1}, + PromptResponse: "Y\n", + PromptDisplayed: true, + }, + "display prompt when threshold is exceeded (variant 4)": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 1}, + PromptResponse: "YES\n", + PromptDisplayed: true, + }, + "display prompt when threshold is exceeded for definitions.SourceTypeGlobPattern": { + Paths: []string{"test_data/definitions_test/**"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 1}, + PromptResponse: "yes\n", + PromptDisplayed: true, + }, + "abort process when prompt is not confirmed": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 1}, + PromptResponse: "no\n", + PromptDisplayed: true, + ExpectedError: errOperationAborted, + }, + "abort process when empty new line is provided": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 1}, + PromptResponse: "\n", + PromptDisplayed: true, + ExpectedError: errOperationAborted, + }, + "return error if project override does not match the object's project": { + Paths: []string{"test_data/definitions_test"}, + ExpectedDefinitions: 2, + Prompt: filesPrompt{Enabled: true, Threshold: 1}, + PromptResponse: "\n", + PromptDisplayed: true, + ExpectedError: errOperationAborted, + }, + } { + t.Run(name, func(t *testing.T) { + cmd := new(cobra.Command) + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetIn(test.In) + + reader := &mockReader{Reader: strings.NewReader(test.PromptResponse)} + test.Prompt.ReadFrom = reader + + d, err := readObjectsDefinitions(&sdk.Config{}, cmd, test.Paths, test.Prompt, false) + + if test.PromptDisplayed { + assert.Equal(t, test.PromptDisplayed, reader.WasRead) + // If any of the test files would contain more than one definition, this would have to corrected. + assert.Equal(t, + fmt.Sprintf(filesPromptPattern, test.Prompt.Threshold, test.ExpectedDefinitions), + out.String()) + } + if test.ExpectedError == nil { + require.NoError(t, err) + assert.Len(t, d, test.ExpectedDefinitions) + } else { + require.Error(t, err) + assert.Equal(t, test.ExpectedError, err) + } + }) + } +} + +type mockReader struct { + WasRead bool + Reader *strings.Reader +} + +func (m *mockReader) Read(p []byte) (n int, err error) { + m.WasRead = true + return m.Reader.Read(p) +} diff --git a/internal/delete.go b/internal/delete.go new file mode 100644 index 0000000..9f6d147 --- /dev/null +++ b/internal/delete.go @@ -0,0 +1,141 @@ +package internal + +import ( + "context" + _ "embed" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/sdk" +) + +type DeleteCmd struct { + client *sdk.Client + projectFlagWasSet bool + definitionPaths []string + dryRun bool + autoConfirm bool +} + +//go:embed delete_example.sh +var deleteExample string + +// NewDeleteCmd returns cobra command delete with all its flags. +func (r *RootCmd) NewDeleteCmd() *cobra.Command { + deleteCmd := &DeleteCmd{} + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete object definition by name or definition file", + Long: getApplyOrDeleteDescription( + "One or more definitions can be specified by name or provide a path to file with definitions to remove."), + Example: deleteExample, + Args: positionalArgsCondition, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + deleteCmd.client = r.GetClient() + if r.Flags.Project != "" { + deleteCmd.projectFlagWasSet = true + } + if deleteCmd.dryRun { + NotifyDryRunFlag() + } + }, + RunE: func(cmd *cobra.Command, args []string) error { return deleteCmd.Run(cmd) }, + } + + RegisterFileFlag(cmd, false, &deleteCmd.definitionPaths) + RegisterDryRunFlag(cmd, &deleteCmd.dryRun) + RegisterAutoConfirmationFlag(cmd, &deleteCmd.autoConfirm) + + // register all subcommands for delete + for _, def := range []struct { + kind manifest.Kind + // plural if not provided will append 's' at the end of a singular manifest.Kind name. + plural string + // aliases always contains the singular lowercase name of an manifest.Kind. + aliases []string + }{ + {kind: manifest.KindAgent}, + {kind: manifest.KindAlertMethod}, + {kind: manifest.KindAlertPolicy, plural: "AlertPolicies"}, + {kind: manifest.KindAlertSilence}, + {kind: manifest.KindAnnotation}, + {kind: manifest.KindDataExport}, + {kind: manifest.KindDirect}, + {kind: manifest.KindProject}, + {kind: manifest.KindRoleBinding}, + {kind: manifest.KindService, aliases: []string{"svc", "svcs"}}, + {kind: manifest.KindSLO}, + } { + if len(def.plural) == 0 { + def.plural = def.kind.String() + "s" + } + cmd.AddCommand(newSubcommand( + deleteCmd, + def.kind, + fmt.Sprintf("Delete the %s.", def.plural), + strings.ToLower(def.plural), + append(def.aliases, def.kind.ToLower(), def.kind.String())...)) + } + + return cmd +} + +func (d DeleteCmd) Run(cmd *cobra.Command) error { + if d.dryRun { + d.client.WithDryRun() + } + if len(d.definitionPaths) == 0 { + return cmd.Usage() + } + objects, err := readObjectsDefinitions( + d.client.Config, + cmd, + d.definitionPaths, + newFilesPrompt(d.client.Config.FilesPromptEnabled, d.autoConfirm, d.client.Config.FilesPromptThreshold), + d.projectFlagWasSet) + if err != nil { + return err + } + printSourcesDetails("Deleting", objects) + if err = d.client.Objects().V1().Delete(cmd.Context(), objects); err != nil { + return err + } + printCommandResult("The resources were successfully deleted.", d.dryRun) + return nil +} + +func newSubcommand( + deleteCmd *DeleteCmd, + kind manifest.Kind, + shortDesc, useCmd string, + aliases ...string, +) *cobra.Command { + sc := &cobra.Command{ + Use: useCmd, + Aliases: aliases, + Short: shortDesc, + Args: cobra.MinimumNArgs(1), //nolint: gomnd + RunE: func(cmd *cobra.Command, args []string) error { + return runSubcommand(cmd.Context(), deleteCmd, kind, args) + }, + } + RegisterDryRunFlag(sc, &deleteCmd.dryRun) + return sc +} + +func runSubcommand(ctx context.Context, deleteCmd *DeleteCmd, kind manifest.Kind, args []string) error { + if err := deleteCmd.client.Objects().V1().DeleteByName( + ctx, + kind, + deleteCmd.client.Config.Project, + args..., + ); err != nil { + return err + } + printCommandResult("The resources were successfully deleted.", deleteCmd.dryRun) + return nil +} diff --git a/internal/delete_example.sh b/internal/delete_example.sh new file mode 100644 index 0000000..324570d --- /dev/null +++ b/internal/delete_example.sh @@ -0,0 +1,20 @@ +# Delete the configuration from slo.yaml. +sloctl delete -f ./slo.yaml + +# Delete resources from multiple different sources at once. +sloctl delete -f ./slo.yaml -f test/config.yaml -f https://nobl9.com/slo.yaml + +# Delete the YAML or JSON passed directly into stdin. +cat slo.yaml | sloctl delete -f - + +# Delete by passing in one or more resource names. +sloctl delete slo my-slo-name + +# Delete the configuration from slo.yaml and set project context if it is not defined in file. +sloctl delete -f ./slo.yaml -p slo + +# Delete the configurations from all the files located at cwd recursively. +sloctl delete -f '**' + +# Delete the configurations from files with 'annotations' name within the whole directory tree. +sloctl delete -f '**/annotations*' diff --git a/internal/flags.go b/internal/flags.go new file mode 100644 index 0000000..0d5e106 --- /dev/null +++ b/internal/flags.go @@ -0,0 +1,55 @@ +package internal + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/nobl9/sloctl/internal/csv" +) + +const ( + flagFile = "file" + flagDryRun = "dry-run" +) + +func NotifyDryRunFlag() { + _, _ = fmt.Fprintln(os.Stderr, "Running in dry run mode, changes will not be applied.") +} + +func RegisterFileFlag(cmd *cobra.Command, required bool, storeIn *[]string) { + cmd.Flags().StringArrayVarP(storeIn, flagFile, "f", []string{}, + "File path, glob pattern or a URL to the configuration in YAML or JSON format."+ + " This option can be used multiple times.") + if required { + _ = cmd.MarkFlagRequired(flagFile) + } +} + +func RegisterDryRunFlag(cmd *cobra.Command, storeIn *bool) { + cmd.Flags().BoolVarP(storeIn, flagDryRun, "", false, + "Submit server-side request without persisting the configured resources.") +} + +func RegisterVerboseFlag(cmd *cobra.Command, storeIn *bool) { + cmd.Flags().BoolVarP(storeIn, "verbose", "v", false, + "Display verbose information about configuration") +} + +func RegisterAutoConfirmationFlag(cmd *cobra.Command, storeIn *bool) { + cmd.Flags().BoolVarP(storeIn, "yes", "y", false, + "Auto confirm files threshold prompt."+ + " Threshold can be changed or disabled in config.toml or via env variables.") +} + +func RegisterOutputFormatFlags(cmd *cobra.Command, outputFormat, fieldSeparator, recordSeparator *string) { + cmd.PersistentFlags().StringVarP(outputFormat, "output", "o", "yaml", + `Output format: one of yaml|json|csv.`) + + cmd.PersistentFlags().StringVarP(fieldSeparator, csv.FieldSeparatorFlag, "", + csv.DefaultFieldSeparator, "Field Separator for CSV.") + + cmd.PersistentFlags().StringVarP(recordSeparator, csv.RecordSeparatorFlag, "", + csv.DefaultRecordSeparator, "Record Separator for CSV.") +} diff --git a/internal/get.go b/internal/get.go new file mode 100644 index 0000000..ab491f1 --- /dev/null +++ b/internal/get.go @@ -0,0 +1,419 @@ +package internal + +import ( + "context" + _ "embed" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + "sync" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/sdk" + objectsV1 "github.com/nobl9/nobl9-go/sdk/endpoints/objects/v1" + + "github.com/nobl9/sloctl/internal/csv" + "github.com/nobl9/sloctl/internal/printer" +) + +//go:embed get_alert_example.sh +var getAlertExample string + +type GetCmd struct { + client *sdk.Client + outputFormat string + labels []string + fieldSeparator string + recordSeparator string + out io.Writer +} + +// NewGetCmd returns cobra command get with all flags for it. +func (r *RootCmd) NewGetCmd() *cobra.Command { + get := &GetCmd{out: os.Stdout} + + cmd := &cobra.Command{ + Use: "get", + Short: "Display one or more than one resource", + Long: `Prints a table of the most important information about the specified resources. +To get more details in output use one of the available flags.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { get.client = r.GetClient() }, + } + + // All flags for 'get' and its subcommands. + get.RegisterFlags(cmd) + + // All subcommands for get. + for _, subCmd := range []struct { + Kind manifest.Kind + Aliases []string + Plural string + Extender func(cmd *cobra.Command) *cobra.Command + }{ + {Kind: manifest.KindAgent, Aliases: []string{"agent", "Agents", "Agent"}, Extender: get.newGetAgentCommand}, + {Kind: manifest.KindAlertMethod}, + {Kind: manifest.KindAlertPolicy, Plural: "AlertPolicies"}, + {Kind: manifest.KindAlert, Extender: get.newGetAlertCommand}, + {Kind: manifest.KindAlertSilence}, + {Kind: manifest.KindAnnotation}, + {Kind: manifest.KindDataExport, Extender: get.newGetDataExportCommand}, + {Kind: manifest.KindDirect}, + {Kind: manifest.KindProject}, + {Kind: manifest.KindRoleBinding}, + {Kind: manifest.KindService, Aliases: []string{"svc", "svcs"}}, + {Kind: manifest.KindSLO}, + {Kind: manifest.KindUserGroup}, + } { + plural := subCmd.Kind.String() + "s" + if len(subCmd.Plural) > 0 { + plural = subCmd.Plural + } + short := fmt.Sprintf("Displays all of the %s.", plural) + use := strings.ToLower(plural) + subCmd.Aliases = append(subCmd.Aliases, subCmd.Kind.ToLower(), subCmd.Kind.String()) + + sc := get.newGetObjectsCommand(subCmd.Kind, short, use, subCmd.Aliases) + if subCmd.Extender != nil { + subCmd.Extender(sc) + } + cmd.AddCommand(sc) + } + + return cmd +} + +func (g *GetCmd) RegisterFlags(cmd *cobra.Command) { + cmd.PersistentFlags().StringArrayVarP(&g.labels, "label", "l", []string{}, + `Filter resource by label. Example: key=value,key2=value2,key2=value3.`) + + // Hidden variables. + mustHide := func(f string) { + if err := cmd.PersistentFlags().MarkHidden(f); err != nil { + panic(err) + } + } + + RegisterOutputFormatFlags(cmd, &g.outputFormat, &g.fieldSeparator, &g.recordSeparator) + mustHide(csv.RecordSeparatorFlag) +} + +func (g *GetCmd) newGetObjectsCommand( + kind manifest.Kind, + short, use string, + aliases []string, +) *cobra.Command { + return &cobra.Command{ + Use: use, + Aliases: aliases, + Short: short, + RunE: func(cmd *cobra.Command, args []string) error { + objects, err := g.getObjects(cmd.Context(), args, kind) + if err != nil { + return err + } + if objects == nil { + return nil + } + if err = g.printObjects(objects); err != nil { + return err + } + return nil + }, + } +} + +// nolint: gocognit +func (g *GetCmd) newGetAlertCommand(cmd *cobra.Command) *cobra.Command { + cmd.Example = getAlertExample + cmd.Long = "Get alerts based on search criteria. You can use specific criteria using flags to find alerts " + + "related to specific SLO, objective, service, alert policy, time range, or alert status.\n\n" + + "For example, you can use the same flag multiple times to find alerts triggered for a given SLO OR " + + "another SLO. Keep in mind that the different types of flags are linked by the AND logical operator.\n\n" + + "Only alerts triggered in given project only (alert project is the same as the SLO project). If you don't " + + "have permission to view SLO in a given project, alerts from that project will not be returned.\n\n" + + alertPolicyNames := cmd.Flags().StringArray( + "alert-policy", + []string{}, + "Get alerts triggered for a given alert policy (name) only.", + ) + sloNames := cmd.Flags().StringArray( + "slo", + []string{}, + "Get alerts triggered for a given SLO (name) only.", + ) + objectiveNames := cmd.Flags().StringArray( + "objective", + []string{}, + "Get alerts triggered for a given objective name of the SLO only.", + ) + serviceNames := cmd.Flags().StringArray( + "service", + []string{}, + "Get alerts triggered for SLOs related to a given service only.", + ) + objectiveValues := cmd.Flags().StringArray( + "objective-value", + []string{}, + "Get alerts triggered for a given objective value of the SLO only.", + ) + resolved := cmd.Flags().Bool( + "resolved", + true, + "Get alerts that are resolved only.", + ) + triggered := cmd.Flags().Bool( + "triggered", + true, + "Get alerts that are still active (not resolved yet) only.", + ) + from := cmd.Flags().String( + "from", + "", + "Get active alerts after `from` time only, based on metric timestamp (RFC3339), "+ + "for example 2023-02-09T10:00:00Z.", + ) + to := cmd.Flags().String( + "to", + "", + "Get active alerts before `to` time only, based on metric timestamp (RFC3339), "+ + "for example 2023-02-09T10:00:00Z.", + ) + + cmd.Flags().SortFlags = false + cmd.Flags().Lookup("objective-value").Hidden = true + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + query := make(url.Values) + + if len(args) > 0 { + query[objectsV1.QueryKeyName] = args + } + if len(*sloNames) > 0 { + query[objectsV1.QueryKeySLOName] = *sloNames + } + if len(*serviceNames) > 0 { + query[objectsV1.QueryKeyServiceName] = *serviceNames + } + if len(*alertPolicyNames) > 0 { + query[objectsV1.QueryKeyAlertPolicyName] = *alertPolicyNames + } + if len(*objectiveNames) > 0 { + query[objectsV1.QueryKeyObjectiveName] = *objectiveNames + } + if len(*objectiveValues) > 0 { + query[objectsV1.QueryKeyObjectiveValue] = *objectiveValues + } + if cmd.Flags().Lookup(objectsV1.QueryKeyFrom).Changed { + query[objectsV1.QueryKeyFrom] = []string{*from} + } + if cmd.Flags().Lookup(objectsV1.QueryKeyTo).Changed { + query[objectsV1.QueryKeyTo] = []string{*to} + } + if cmd.Flags().Lookup(objectsV1.QueryKeyResolved).Changed { + query[objectsV1.QueryKeyResolved] = []string{strconv.FormatBool(*resolved)} + } + if cmd.Flags().Lookup(objectsV1.QueryKeyTriggered).Changed { + query[objectsV1.QueryKeyTriggered] = []string{strconv.FormatBool(*triggered)} + } + + objects, truncatedMax, err := g.client.Objects().V1().GetAlerts( + cmd.Context(), + http.Header{sdk.HeaderProject: []string{g.client.Config.Project}}, + query, + ) + if err != nil { + return err + } + if len(objects) == 0 { + return nil + } + if err = g.printObjects(objects); err != nil { + return err + } + if truncatedMax > 0 { + fmt.Fprintf(os.Stderr, "Warning: %d new alerts have been returned from the API according to the "+ + "provided searching criteria. Specify more restrictive filters (by SLO, objective, service, "+ + "alert policy, time range, or alert status) to get more limited results.\n", truncatedMax) + } + return nil + } + return cmd +} + +func (g *GetCmd) newGetDataExportCommand(cmd *cobra.Command) *cobra.Command { + displayExternalID := cmd.Flags().Bool( + "aws-external-id", + false, + "Display AWS external id, which will be used by Nobl9 to assume the IAM role when performing data export", + ) + err := cmd.Flags().MarkDeprecated( + "aws-external-id", "use `sloctl aws-iam-ids dataexport` instead", + ) + if err != nil { + cmd.PrintErr(err) + } + + wrap := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if *displayExternalID { + id, err := g.client.AuthData().V1().GetDataExportIAMRoleIDs(cmd.Context()) + if err != nil { + return err + } + fmt.Println(id) + return nil + } + return wrap(cmd, args) + } + return cmd +} + +func (g *GetCmd) newGetAgentCommand(cmd *cobra.Command) *cobra.Command { + withAccessKeysFlag := cmd.Flags().BoolP("with-keys", "k", false, + `Displays client_secret and client_id.`) + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + objects, err := g.getObjects(cmd.Context(), args, manifest.KindAgent) + if err != nil || objects == nil { + return err + } + var agents interface{} + if *withAccessKeysFlag { + agents, err = g.getAgentsWithSecrets(cmd.Context(), objects) + if err != nil { + return err + } + } else { + agents = objects + } + if err = g.printObjects(agents); err != nil { + return err + } + return nil + } + return cmd +} + +func (g *GetCmd) getAgentsWithSecrets(ctx context.Context, objects []manifest.Object) ([]v1alpha.GenericObject, error) { + agents := make([]v1alpha.GenericObject, 0, len(objects)) + var mu sync.Mutex + eg, ctx := errgroup.WithContext(ctx) + for i := range objects { + i := i + eg.Go(func() error { + agent, ok := objects[i].(v1alpha.GenericObject) + if !ok { + return nil + } + withSecrets, err := g.enrichAgentWithSecrets(ctx, agent) + if err != nil { + return err + } + mu.Lock() + agents = append(agents, withSecrets) + mu.Unlock() + return nil + }) + } + if err := eg.Wait(); err != nil { + return nil, err + } + sort.Slice(agents, func(i, j int) bool { + return agents[i].GetName() < agents[j].GetName() + }) + return agents, nil +} + +func (g *GetCmd) enrichAgentWithSecrets( + ctx context.Context, + agent v1alpha.GenericObject, +) (v1alpha.GenericObject, error) { + keys, err := g.client.AuthData().V1().GetAgentCredentials(ctx, agent.GetProject(), agent.GetName()) + if err != nil { + return nil, err + } + meta, ok := agent["metadata"].(map[string]interface{}) + if !ok { + return agent, nil + } + meta["client_id"] = keys.ClientID + meta["client_secret"] = keys.ClientSecret + agent["metadata"] = meta + return agent, nil +} + +func (g *GetCmd) getObjects(ctx context.Context, args []string, kind manifest.Kind) ([]manifest.Object, error) { + if kind == manifest.KindAlert && g.client.Config.Project != sdk.ProjectsWildcard { + return nil, errors.New("'sloctl get alerts' does not support Project filtering," + + " explicitly pass '-A' flag to fetch all Alerts.") + } + query := url.Values{ + objectsV1.QueryKeyName: args, + objectsV1.QueryKeyLabels: parseFilterLabel(g.labels), + } + header := http.Header{sdk.HeaderProject: []string{g.client.Config.Project}} + objects, err := g.client.Objects().V1().Get(ctx, kind, header, query) + if err != nil { + return nil, err + } + if len(objects) == 0 { + switch kind { + case manifest.KindProject, manifest.KindUserGroup: + fmt.Printf("No resources found.\n") + default: + fmt.Printf("No resources found in '%s' project.\n", g.client.Config.Project) + } + return nil, nil + } + return objects, nil +} + +func parseFilterLabel(filterLabels []string) []string { + labels := make(v1alpha.Labels) + for _, filterLabel := range filterLabels { + filteredLabels := strings.Split(filterLabel, ",") + for _, currentLabel := range filteredLabels { + values := strings.Split(currentLabel, "=") + key := values[0] + if _, ok := labels[key]; !ok { + labels[key] = nil + } + if len(values) == 2 { + labels[key] = append(labels[key], values[1]) + } + } + } + var strLabels []string + for key, values := range labels { + if len(values) > 0 { + for _, value := range values { + strLabels = append(strLabels, fmt.Sprintf("%s:%s", key, value)) + } + } else { + strLabels = append(strLabels, key) + } + } + return strLabels +} + +func (g *GetCmd) printObjects(objects interface{}) error { + p, err := printer.New(g.out, printer.Format(g.outputFormat), g.fieldSeparator, g.recordSeparator) + if err != nil { + return err + } + if err = p.Print(objects); err != nil { + return err + } + return nil +} diff --git a/internal/get_alert_example.sh b/internal/get_alert_example.sh new file mode 100644 index 0000000..010205d --- /dev/null +++ b/internal/get_alert_example.sh @@ -0,0 +1,28 @@ +# Get all alerts triggered in my organization (max 1000). +sloctl get alert -A + +# Get only active (not resolved yet) alerts in my organization. +sloctl get alert --triggered -A + +# Get a specific alert by the alert ID. +sloctl get alert ce1a2a10-d74d-477f-b574-b278ee54e02b -A + +# Get alerts related to the reportsapi service or usersapi service in project prod. +sloctl get alert --service reportsapi --service usersapi -p prod + +# Get only resolved alerts for the specific alert policy and SLO in the specified project. +sloctl get alert --resolved --alert-policy slow-burn --slo usersapi-latency -p prod + +# Get alerts triggered for the slo usersapi-availability AND objective objective-1 in project prod. +sloctl get alert --slo usersapi-availability --objective objective-1 -p prod + +# Get alerts triggered for slo usersapi-latency AND objective objective-1 OR objective-2 in project prod. +sloctl get alert --slo usersapi-latency --objective objective-1 --objective objective-2 -p prod + +# Get alerts by a time range: +# - Alerts that were active yesterday: +sloctl get alert --from 2023-03-22T00:00:00Z --to 2023-03-22T23:59:59Z -A +# - Alerts that have been active yesterday and are still active now: +sloctl get alert --from 2023-03-22T00:00:00Z -A +# - Alerts that were active until today: +sloctl get alert --to 2023-03-22T00:00:00Z -A diff --git a/internal/get_test.go b/internal/get_test.go new file mode 100644 index 0000000..5a9a083 --- /dev/null +++ b/internal/get_test.go @@ -0,0 +1,114 @@ +//go:build unit_test + +package internal + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + authDataV1 "github.com/nobl9/nobl9-go/sdk/endpoints/authdata/v1" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nobl9/nobl9-go/manifest" + v1alphaParser "github.com/nobl9/nobl9-go/manifest/v1alpha/parser" + "github.com/nobl9/nobl9-go/sdk" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" +) + +//go:embed test_data/agent_with_keys_response.yaml +var agentWithKeysResponse []byte + +func TestGet_AgentKeys(t *testing.T) { + rt := mockRoundTripper{ + T: t, + GetAgentResponse: []v1alpha.GenericObject{ + { + "apiVersion": manifest.VersionV1alpha, + "kind": manifest.KindAgent, + "metadata": map[string]interface{}{ + "name": "obi-wan", + "project": "secret-mission", + }, + }, + { + "apiVersion": manifest.VersionV1alpha, + "kind": manifest.KindAgent, + "metadata": map[string]interface{}{ + "name": "luke-skywalker", + "project": "jedi-training", + }, + }, + }, + GetAgentCredsResponse: map[string]authDataV1.M2MAppCredentials{ + "secret-mission": {ClientID: "super-secret-obi", ClientSecret: "even-more-secret-obi!"}, + "jedi-training": {ClientID: "super-secret-luke", ClientSecret: "even-more-secret-luke!"}, + }, + } + + client, err := sdk.NewClient(&sdk.Config{ + DisableOkta: true, + Project: sdk.ProjectsWildcard, + }) + require.NoError(t, err) + client.HTTP = &http.Client{Transport: rt} + var out bytes.Buffer + g := GetCmd{ + client: client, + outputFormat: "yaml", + out: &out, + } + + cmd := cobra.Command{} + g.newGetAgentCommand(&cmd) + f := cmd.Flag("with-keys") + require.NoError(t, f.Value.Set("true")) + v1alphaParser.UseGenericObjects = true + err = cmd.Execute() + v1alphaParser.UseGenericObjects = false + require.NoError(t, err) + + assert.Equal(t, "*", g.client.Config.Project, "Project must not be overwritten") + assert.YAMLEq(t, string(agentWithKeysResponse), out.String()) +} + +type mockRoundTripper struct { + T *testing.T + GetAgentResponse []v1alpha.GenericObject + GetAgentCredsResponse map[string]authDataV1.M2MAppCredentials +} + +func (m mockRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + m.T.Helper() + rec := httptest.NewRecorder() + switch r.URL.Path { + case "/get/agent": + data, err := json.Marshal(m.GetAgentResponse) + if err != nil { + return nil, err + } + _, _ = rec.Write(data) + case "/internal/agent/clientcreds": + split := strings.Split(r.URL.RawQuery, "=") + require.Len(m.T, split, 2, "expected exactly one query parameter") + projectName := r.Header.Get(sdk.HeaderProject) + data, err := json.Marshal(m.GetAgentCredsResponse[projectName]) + if err != nil { + return nil, err + } + _, _ = rec.Write(data) + default: + fmt.Println(r.URL) + panic("implement me") + } + return rec.Result(), nil +} diff --git a/internal/printer/printer.go b/internal/printer/printer.go new file mode 100644 index 0000000..19f4e1e --- /dev/null +++ b/internal/printer/printer.go @@ -0,0 +1,82 @@ +// Package printer provides utilities for printing standard structures from api in convenient formats +package printer + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/goccy/go-yaml" + + "github.com/nobl9/sloctl/internal/csv" +) + +// Format represents supported printing outputs +type Format string + +// All supported output formats by a Printer +const ( + YAMLFormat Format = "yaml" + JSONFormat Format = "json" + CSVFormat Format = "csv" +) + +// Printer represents generic printer for cli +type Printer interface { + Print(interface{}) error +} + +// New returns an instance of a proper printer based on format parameter +func New(out io.Writer, format Format, fieldSeparator, recordSeparator string) (Printer, error) { + switch format { + case JSONFormat: + return &jsonPrinter{Out: out}, nil + case YAMLFormat: + return &yamlPrinter{Out: out}, nil + case CSVFormat: + return &csvPrinter{Out: out, fieldSeparator: fieldSeparator, recordSeparator: recordSeparator}, nil + default: + return nil, fmt.Errorf("unknown output format %q", format) + } +} + +type jsonPrinter struct { + Out io.Writer +} + +func (p *jsonPrinter) Print(content interface{}) error { + b, err := json.MarshalIndent(content, "", " ") + if err != nil { + return err + } + _, err = p.Out.Write(b) + return err +} + +type yamlPrinter struct { + Out io.Writer +} + +func (p *yamlPrinter) Print(content interface{}) error { + b, err := yaml.Marshal(content) + if err != nil { + return err + } + _, err = p.Out.Write(b) + return err +} + +type csvPrinter struct { + Out io.Writer + fieldSeparator string + recordSeparator string +} + +func (p *csvPrinter) Print(content interface{}) error { + b, err := csv.Marshal(content, p.fieldSeparator, p.recordSeparator) + if err != nil { + return err + } + _, err = p.Out.Write(b) + return err +} diff --git a/internal/replay.go b/internal/replay.go new file mode 100644 index 0000000..d9c3408 --- /dev/null +++ b/internal/replay.go @@ -0,0 +1,495 @@ +package internal + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-playground/validator/v10" + "github.com/goccy/go-yaml" + "github.com/mitchellh/colorstring" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + v1alphaSLO "github.com/nobl9/nobl9-go/manifest/v1alpha/slo" + "github.com/nobl9/nobl9-go/sdk" + objectsV1 "github.com/nobl9/nobl9-go/sdk/endpoints/objects/v1" + sdkModels "github.com/nobl9/nobl9-go/sdk/models" +) + +type ReplayCmd struct { + client *sdk.Client + from TimeValue + configPaths []string + sloName string +} + +//go:embed replay_example.sh +var replayExample string + +func (r *RootCmd) NewReplayCmd() *cobra.Command { + replay := &ReplayCmd{} + + cmd := &cobra.Command{ + Use: "replay", + Short: "Retrieve historical SLI data and recalculate their SLO error budgets.", + Long: "Replay pulls in the historical data while your SLO collects new data in real-time. " + + "The historical and current data are merged, producing an error budget calculated for the entire period. " + + "Refer to https://docs.nobl9.com/Features/replay?_highlight=replay for more details on Replay.\n\n" + + "The 'replay' command allows you to import data for multiple SLOs in bulk. " + + "Before running the Replays it will verify if the SLOs you've provided are eligible for Replay. " + + "It will only run a single Replay simultaneously (current limit for concurrent Replays). " + + "When any Replay fails, it will attempt the import for the next SLO. " + + "Importing data takes time: Replay for a single SLO may take several minutes up to an hour. " + + "During that time, the command keeps on running, periodically checking the status of Replay. " + + "If you cancel the program execution at any time, the current Replay in progress will not be revoked.", + Example: replayExample, + Args: replay.arguments, + PersistentPreRun: func(cmd *cobra.Command, args []string) { replay.client = r.GetClient() }, + RunE: func(cmd *cobra.Command, args []string) error { return replay.Run(cmd) }, + } + + RegisterFileFlag(cmd, false, &replay.configPaths) + cmd.Flags().Var(&replay.from, "from", "Sets the start of Replay time window.") + + return cmd +} + +func (r *ReplayCmd) Run(cmd *cobra.Command) error { + if r.client.Config.Project == "*" { + return errProjectWildcardIsNotAllowed + } + replays, err := r.prepareConfigs() + if err != nil { + return err + } + _, err = r.RunReplays(cmd, replays) + return err +} + +func (r *ReplayCmd) RunReplays(cmd *cobra.Command, replays []ReplayConfig) (failedReplays int, err error) { + if err = r.verifySLOs(cmd.Context(), replays); err != nil { + return 0, err + } + + failedIndexes := make([]int, 0) + for i, replay := range replays { + cmd.Println(colorstring.Color(fmt.Sprintf( + "[cyan][%d/%d][reset] SLO: %s, Project: %s, From: %s, To: %s", + i+1, len(replays), replay.SLO, replay.Project, + replay.From.Format(timeLayout), time.Now().In(replay.From.Location()).Format(timeLayout)))) + + spinner := NewSpinner("Importing data...") + spinner.Go() + err = r.runReplay(cmd.Context(), replay) + spinner.Stop() + + if err != nil { + cmd.Println(colorstring.Color("[red]Import failed:[reset] " + err.Error())) + failedIndexes = append(failedIndexes, i) + continue + } + cmd.Println(colorstring.Color("[green]Import succeeded![reset]")) + } + if len(replays) > 0 { + r.printSummary(cmd, replays, failedIndexes) + } + return len(failedIndexes), nil +} + +type ReplayConfig struct { + Project string `json:"project" validate:"required"` + SLO string `json:"slo" validate:"required"` + From time.Time `json:"from" validate:"required"` + + metricSource v1alphaSLO.MetricSourceSpec +} + +// We can only give an estimate here, since there's no 'to' for Replay. +// We're always sending the duration for Replay, but never specify when it starts. +// The start timestamp of Replay is beyond our control. +// However, it's better to import more than less, that's why we're extending +// the duration here to account for that unknown offset factor. +const startOffsetMinutes = 5 + +func (r ReplayConfig) ToReplay(timeNow time.Time) sdkModels.Replay { + windowDuration := timeNow.Sub(r.From) + return sdkModels.Replay{ + Project: r.Project, + Slo: r.SLO, + Duration: sdkModels.ReplayDuration{ + Unit: sdkModels.DurationUnitMinute, + Value: startOffsetMinutes + int(windowDuration.Minutes()), + }, + } +} + +func (r *ReplayCmd) prepareConfigs() ([]ReplayConfig, error) { + var replays []ReplayConfig + val := validator.New() + + unique := make(map[string]struct{}) + key := func(s, p string) string { return s + p } + for _, path := range r.configPaths { + c, err := r.readConfigFile(path) + if err != nil { + return nil, errors.Wrapf(err, "failed to read Replay config from: %s", path) + } + for i := range c { + if c[i].From.IsZero() { + c[i].From = r.from.Time + } + if len(c[i].Project) == 0 { + c[i].Project = r.client.Config.Project + } + if err = val.Struct(c[i]); err != nil { + return nil, errors.Wrap(err, "Replay config entry failed validation") + } + k := key(c[i].SLO, c[i].Project) + if _, exists := unique[k]; exists { + return nil, errors.Errorf( + "duplicated Replay definition detected for '%s' SLO in '%s' Project", + c[i].SLO, c[i].Project) + } + unique[k] = struct{}{} + } + replays = append(replays, c...) + } + + if len(replays) == 0 { + replays = append(replays, ReplayConfig{ + Project: r.client.Config.Project, + SLO: r.sloName, + From: r.from.Time, + }) + } + return replays, nil +} + +var ( + errReplayInvalidOptions = errors.New("you must either run 'sloctl replay' for a single SLO," + + " providing its name as an argument, or provide configuration file using '-f' flag, but not both") + errReplayTooManyArgs = errors.New("'replay' command accepts a single SLO name," + + " If you want to run it for multiple SLOs provide a configuration file instead using '-f' flag") + errReplayMissingFromArg = errors.Errorf("when running 'sloctl replay' for a single SLO,"+ + " you must provide Replay window start time (%s layout) with '--from' flag", timeLayoutString) + errProjectWildcardIsNotAllowed = errors.New( + "wildcard Project is not allowed, you must provide specific Project name(s)") +) + +func (r *ReplayCmd) arguments(cmd *cobra.Command, args []string) error { + if len(r.configPaths) == 0 && len(args) == 0 { + _ = cmd.Usage() + return errReplayInvalidOptions + } + if len(args) > 1 { + return errReplayTooManyArgs + } + if len(r.configPaths) > 0 && len(args) == 1 { + return errReplayInvalidOptions + } + if len(args) == 1 && r.from.IsZero() { + return errReplayMissingFromArg + } + if len(args) == 1 { + r.sloName = args[0] + } + return nil +} + +type TimeValue struct{ time.Time } + +const ( + timeLayout = time.RFC3339 + timeLayoutString = "RFC3339" +) + +func (t *TimeValue) String() string { + if t.IsZero() { + return "" + } + return t.Format(timeLayout) +} + +func (t *TimeValue) Set(s string) (err error) { + t.Time, err = time.Parse(timeLayout, s) + return +} + +func (t *TimeValue) Type() string { + return "time" +} + +func (r *ReplayCmd) readConfigFile(path string) ([]ReplayConfig, error) { + data, err := os.ReadFile(path) // #nosec G304 + if err != nil { + return nil, err + } + var replays []ReplayConfig + if err = yaml.Unmarshal(data, &replays); err != nil { + return nil, err + } + return replays, nil +} + +// averageReplayDuration is used to calculate when running bulk Replay to calculate time offset for each SLO. +const averageReplayDuration = 20 * time.Minute + +func (r *ReplayCmd) verifySLOs(ctx context.Context, replays []ReplayConfig) error { + sloNames := make([]string, 0, len(replays)) + for i := range replays { + sloNames = append(sloNames, replays[i].SLO) + } + if r.client.Config.Project == "" { + r.client.Config.Project = sdk.ProjectsWildcard + } + + // Find non-existent or RBAC protected SLOs. + // We're also filling the Data Source spec here for ReplayConfig. + data, err := r.doRequest( + ctx, + http.MethodGet, + endpointGetSLO, + "*", + url.Values{objectsV1.QueryKeyName: sloNames}, + nil) + if err != nil { + return err + } + var slos []v1alphaSLO.SLO + if err = json.Unmarshal(data, &slos); err != nil { + return err + } + missingSLOs := make([]string, 0) +outer: + for i := range replays { + for j := range slos { + if replays[i].SLO == slos[j].Metadata.Name && replays[i].Project == slos[j].Metadata.Project { + replays[i].metricSource = slos[j].Spec.Indicator.MetricSource + continue outer + } + } + missingSLOs = append( + missingSLOs, + fmt.Sprintf("'%s' SLO in '%s' Project", replays[i].SLO, replays[i].Project)) + } + if len(missingSLOs) > 0 { + return errors.Errorf("Some of the SLOs marked for Replay were not found or"+ + " you don't have permissions to view them: \n - %s", strings.Join(missingSLOs, "\n - ")) + } + + // Check Replay availability. + notAvailable := make([]string, 0) + mu := sync.Mutex{} + eg, ctx := errgroup.WithContext(ctx) + eg.SetLimit(10) + for i := range replays { + i := i + eg.Go(func() error { + c := replays[i] + timeNow := time.Now() + tt := c.ToReplay(timeNow) + offset := i * int(averageReplayDuration.Minutes()) + expectedDuration := offset + tt.Duration.Value + av, err := r.getReplayAvailability(ctx, c, tt.Duration.Unit, expectedDuration) + if err != nil { + return errors.Wrapf(err, + "failed to check Replay availability for '%s' SLO in '%s' Project", c.SLO, c.Project) + } + if !av.Available { + mu.Lock() + notAvailable = append(notAvailable, + fmt.Sprintf("['%s' SLO in '%s' Project] %s", + c.SLO, c.Project, r.replayUnavailabilityReasonExplanation( + av.Reason, + c, + time.Duration(expectedDuration)*time.Minute, + time.Duration(offset)*time.Minute, + timeNow))) + mu.Unlock() + } + return nil + }) + } + if err = eg.Wait(); err != nil { + return err + } + if len(notAvailable) > 0 { + return errors.Errorf("The following SLOs are not available for Replay: \n - %s", + strings.Join(notAvailable, "\n - ")) + } + return nil +} + +const replayStatusCheckInterval = 30 * time.Second + +func (r *ReplayCmd) runReplay(ctx context.Context, config ReplayConfig) error { + _, err := r.doRequest(ctx, http.MethodPost, endpointReplayPost, config.Project, nil, config.ToReplay(time.Now())) + if err != nil { + return errors.Wrap(err, "failed to start new Replay") + } + ticker := time.NewTicker(replayStatusCheckInterval) + for { + select { + case <-ticker.C: + status, err := r.getReplayStatus(ctx, config) + if err != nil { + return errors.Wrap(err, "failed to get for Replay status") + } + switch status { + case sdkModels.ReplayStatusFailed: + return errors.New("Replay has failed") + case sdkModels.ReplayStatusCompleted: + return nil + default: + continue + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (r *ReplayCmd) getReplayAvailability( + ctx context.Context, + config ReplayConfig, + durationUnit string, + durationValue int, +) (availability sdkModels.ReplayAvailability, err error) { + values := url.Values{ + "dataSource": {config.metricSource.Name}, + "dataSourceKind": {config.metricSource.Kind.String()}, + "dataSourceProject": {config.metricSource.Project}, + "durationUnit": {durationUnit}, + "durationValue": {strconv.Itoa(durationValue)}, + } + data, err := r.doRequest(ctx, http.MethodGet, endpointReplayGetAvailability, config.Project, values, nil) + if err != nil { + return + } + if err = json.Unmarshal(data, &availability); err != nil { + return + } + return +} + +func (r *ReplayCmd) getReplayStatus( + ctx context.Context, + config ReplayConfig, +) (string, error) { + data, err := r.doRequest( + ctx, + http.MethodGet, + fmt.Sprintf(endpointReplayGetStatus, config.SLO), + config.Project, + nil, + nil) + if err != nil { + return "", err + } + var ws sdkModels.ReplayWithStatus + if err = json.Unmarshal(data, &ws); err != nil { + return "", err + } + return ws.Status.Status, nil +} + +const ( + endpointReplayPost = "/timetravel" + endpointReplayGetStatus = "/timetravel/%s" + endpointReplayGetAvailability = "/internal/timemachine/availability" + endpointGetSLO = "/get/slo" +) + +func (r *ReplayCmd) doRequest( + ctx context.Context, + method, endpoint, project string, + values url.Values, + payload interface{}, +) ([]byte, error) { + var body io.Reader + if payload != nil { + buf := new(bytes.Buffer) + if err := json.NewEncoder(buf).Encode(payload); err != nil { + return nil, err + } + body = buf + } + header := http.Header{sdk.HeaderProject: []string{project}} + req, err := r.client.CreateRequest(ctx, method, endpoint, header, values, body) + if err != nil { + return nil, err + } + resp, err := r.client.HTTP.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return nil, errors.Errorf("bad response (status: %d): %s", resp.StatusCode, string(data)) + } + return io.ReadAll(resp.Body) +} + +func (r *ReplayCmd) replayUnavailabilityReasonExplanation( + reason string, + replay ReplayConfig, + expectedDuration, replayOffset time.Duration, + timeNow time.Time, +) string { + switch reason { + case sdkModels.ReplayIntegrationDoesNotSupportReplay: + return fmt.Sprintf("%s Data Source does not support Replay yet", replay.metricSource.Kind) + case sdkModels.ReplayAgentVersionDoesNotSupportReplay: + return fmt.Sprintf("Update your '%s' Agent in '%s' Project"+ + " version to the latest to use Replay for this Data Source.", + replay.metricSource.Name, replay.metricSource.Project) + case sdkModels.ReplayMaxHistoricalDataRetrievalTooLow: + var offsetNotice string + if replayOffset > 0 { + offsetNotice = fmt.Sprintf( + " + %s (offset for each next replay run in bulk is increased by an average of %s)", + replayOffset, averageReplayDuration) + } + return fmt.Sprintf( + "Value configured for spec.historicalDataRetrieval.maxDuration.value"+ + " for '%s' Data Source in '%s' Project is lower than the duration you're trying to run Replay for."+ + " The calculated duration is: %s, calculated from: %s (time.Now) - %s (from)"+ + " + %dm (start offset to ensure Replay covers the desired time window) %s."+ + " Edit the Data Source and run Replay once again.", + replay.metricSource.Name, replay.metricSource.Project, expectedDuration.String(), + timeNow.Format(timeLayout), r.from.Format(timeLayout), startOffsetMinutes, offsetNotice) + case sdkModels.ReplayConcurrentReplayRunsLimitExhausted: + return "You've exceeded the limit of concurrent Replay runs. Wait until the current Replay(s) are done." + case sdkModels.ReplayUnknownAgentVersion: + return "Your Agent isn't connected to the Data Source. Deploy the Agent and run Replay once again." + default: + return reason + } +} + +func (r *ReplayCmd) printSummary(cmd *cobra.Command, replays []ReplayConfig, failedIndexes []int) { + if len(failedIndexes) == 0 { + cmd.Printf("\nSuccessfully imported data for all %d SLOs.\n", len(replays)) + } else { + failedDetails := make([]string, 0, len(failedIndexes)) + for _, i := range failedIndexes { + fr, _ := json.Marshal(replays[i]) + failedDetails = append(failedDetails, string(fr)) + } + cmd.Printf("\nSuccessfully imported data for %d and failed for %d SLOs:\n - %s\n", + len(replays)-len(failedIndexes), len(failedIndexes), strings.Join(failedDetails, "\n - ")) + } +} diff --git a/internal/replay_example.sh b/internal/replay_example.sh new file mode 100644 index 0000000..e881cce --- /dev/null +++ b/internal/replay_example.sh @@ -0,0 +1,27 @@ +# Replay SLO 'my-slo' in Project 'my-project' data from 2023-03-02 15:00:00 UTC until now. +sloctl replay -p my-project --from=2023-03-02T15:00:00Z my-slo + +# Replay SLOs using file configuration from replay.yaml +sloctl replay -f ./replay.yaml + +# Read the configuration from stdin. +sloctl replay <./replay.yaml + +# If the project is not set, it is inferred from Nobl9 config.toml for the current context. +# If 'from' is not provided in the config file, you must specify it with '--from' flag. +# Setting 'project' or 'from' via flags does not take precedence over the values set in config. +cat <&2 "The following file(s) are not formatted:\n%s\n" "$CHANGED" + exit 1 + else + echo "Looks good!" + fi +} + +main "$@" diff --git a/scripts/check-generate.sh b/scripts/check-generate.sh new file mode 100755 index 0000000..676ac99 --- /dev/null +++ b/scripts/check-generate.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -e + +ENUM_PATH="*_enum.go" +TMP_DIR=$(mktemp -d) + +cleanup_git() { + git -C "$TMP_DIR" clean -df + git -C "$TMP_DIR" checkout -- . +} + +main() { + cp -r . "$TMP_DIR" + cleanup_git + + make -C "$TMP_DIR" generate + + CHANGED=$(git -C "$TMP_DIR" diff --name-only "${ENUM_PATH}") + if [ -n "${CHANGED}" ]; then + printf >&2 "There are generated changes that are not committed:\n%s\n" "$CHANGED" + exit 1 + else + echo "Looks good!" + fi +} + +main "$@" diff --git a/scripts/check-trailing-whitespaces.js b/scripts/check-trailing-whitespaces.js new file mode 100644 index 0000000..54dab83 --- /dev/null +++ b/scripts/check-trailing-whitespaces.js @@ -0,0 +1,51 @@ +/* + Linter which checks if all files under git control do not contain any trailing + white spaces (both spaces and tabs characters), moreover non-text files are + excluded from check based on extension from array fileExtensionsToIgnore + Requires git available in PATH and can be run only in a repository +*/ + +import {readFile} from 'fs' +import {spawnSync} from 'child_process' + +const fileExtensionsToIgnore = ['.ico', '.png'] + +// get all files under git control +const gitListFiles = spawnSync('git', ['ls-tree', '-r', 'HEAD', '--name-only']) +if (gitListFiles.stderr.toString() !== "") { + console.error(`Unexpected error occurred: ${gitListFiles.stderr.toString()}`) + process.exit(2) +} + +const filesToCheck = gitListFiles.stdout.toString().split('\n').filter(file => + fileExtensionsToIgnore.every(extension => !file.endsWith(extension)) +) + +const noTrailingWhitespaces = new RegExp(/[ \t]+$/gm) +filesToCheck.forEach(file => { + readFile(file, 'utf8', (_, content) => { + const match = noTrailingWhitespaces.exec(content); + if (match) { + console.error(`Found trailing whitespaces: ${file}:${findLineNumberByIndex(match.input, match.index)}`) + process.exitCode = 1 + } + }) +}) + +function findLineNumberByIndex(input, index) { + const lines = input.split('\n'); + let currentIndex = 0; + + for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { + const line = lines[lineNumber]; + const lineLength = line.length + 1; // Add 1 for the newline character + + if (currentIndex + lineLength > index) { + return lineNumber + 1; // Line numbers are usually 1-based + } + + currentIndex += lineLength; + } + + return -1; // Index is out of bounds +} \ No newline at end of file diff --git a/scripts/ensure_installed.sh b/scripts/ensure_installed.sh new file mode 100755 index 0000000..4749beb --- /dev/null +++ b/scripts/ensure_installed.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -e + +LOCAL_BIN_DIR="${LOCAL_BIN_DIR:-./bin}" + +_binary() { + if [ ! -f "${LOCAL_BIN_DIR}/${1}" ]; then + echo "$1 was not found in $LOCAL_BIN_DIR" >&2 + make "install/$1" + fi +} + +# It's cheaper to run yarn install then do any other checks. +_yarn() { + make "install/yarn" +} + +case "$1" in + binary) _binary "$2" ;; + yarn) _yarn "$2" ;; + *) echo "invalid source provided: $1" >&2 && exit 1 ;; +esac diff --git a/scripts/format-cspell-config.js b/scripts/format-cspell-config.js new file mode 100644 index 0000000..0304779 --- /dev/null +++ b/scripts/format-cspell-config.js @@ -0,0 +1,30 @@ +/* + Formatter which works on cspell config file and: + - Sorts the 'words' list. + - Removes duplicates from 'words' list. +*/ + +import YAML from 'yaml'; +import { readFileSync, writeFileSync } from 'fs'; + +const CSPELL_CONFIG = "cspell.yaml" + +function format() { + const f = readFileSync(CSPELL_CONFIG, 'utf8') + const yaml = YAML.parseDocument(f, { keepSourceTokens: true }) + + let words = yaml.get('words') + words.items.sort() + let set = new Set() + words.items = words.items.filter((w) => { + if (!set.has(w.value)) { + set.add(w.value) + return true + } + return false + }) + + writeFileSync(CSPELL_CONFIG, yaml.toString()) +} + +try { format() } catch (err) { console.error(err) } diff --git a/scripts/makefile-help.awk b/scripts/makefile-help.awk new file mode 100755 index 0000000..0e601c1 --- /dev/null +++ b/scripts/makefile-help.awk @@ -0,0 +1,18 @@ +#!/usr/bin/awk -f + +BEGIN { + FS = ":" + printf "Targets:\n" +} + +!/^##/ && f > 0 { + printf " \033[36m%-30s\033[0m %s\n", $1, r + f = 0 + r = "" +} +/^##/ { + f = f + 1 + sub(/^## /,"",$0) +} +f == 1 { r = $0 } +f > 1 { r = r " " $0 } \ No newline at end of file diff --git a/test/Dockerfile.e2e b/test/Dockerfile.e2e new file mode 100644 index 0000000..8208a74 --- /dev/null +++ b/test/Dockerfile.e2e @@ -0,0 +1,14 @@ +FROM bats/bats:v1.10.0 + +RUN apk --no-cache --update add \ + gettext jq git python3 py3-pip +RUN pip install yq + +WORKDIR /sloctl + +COPY ./test ./test +COPY --from=sloctl-e2e-test-bin /usr/bin/sloctl /usr/bin/sloctl + +ENV SLOCTL_NO_CONFIG_FILE=true +# Required for bats pretty printing. +ENV TERM=linux diff --git a/test/Dockerfile.unit b/test/Dockerfile.unit new file mode 100644 index 0000000..b3f56c6 --- /dev/null +++ b/test/Dockerfile.unit @@ -0,0 +1,13 @@ +FROM bats/bats:v1.10.0 + +RUN apk --no-cache --update add \ + gettext jq git python3 py3-pip +RUN pip install yq + +WORKDIR /sloctl + +COPY ./test ./test +COPY --from=sloctl-unit-test-bin /usr/bin/sloctl /usr/bin/sloctl + +# Required for bats pretty printing. +ENV TERM=linux diff --git a/test/apply-and-delete.bats b/test/apply-and-delete.bats new file mode 100644 index 0000000..8b6532e --- /dev/null +++ b/test/apply-and-delete.bats @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# bats file_tags=e2e + +# setup is run before each test. +setup() { + load "test_helper/load" + load_lib "bats-assert" + + generate_inputs "$BATS_TEST_TMPDIR" +} + +@test "read separate documents from file" { + input="${TEST_INPUTS}/separate-documents.yaml" + + # apply + run_sloctl apply -f "$input" + assert_success + assert_output - < error" { + # These changes won't (or at least shouldn't) take any effect. + # To make it easier to test the output we use the static names, without the generated hash. + input="${TEST_SUITE_INPUTS}/$(basename "$BATS_TEST_FILENAME" .bats)/project-flag-differs.yaml" + + project_flag_mismatch_project="kamino" + # Prefer multiline string over heredoc since this is a one liner, this way we keep it somewhat readable. + project_flag_mismatch_error="Error: \ +The death-star project from the provided object destroyer.death-star \ +does not match the project '$project_flag_mismatch_project'. \ +You must pass '--project=death-star' to perform this operation or \ +allow the Project to be inferred from the object definition." + + # apply + run_sloctl apply -f "$input" -p "$project_flag_mismatch_project" + assert_failure + assert_output "$project_flag_mismatch_error" + + # delete + run_sloctl apply -f "$input" -p "$project_flag_mismatch_project" + assert_failure + assert_output "$project_flag_mismatch_error" +} + +@test "read from json file" { + input="${TEST_INPUTS}/single-object.json" + + # apply + run_sloctl apply -f "$input" + assert_success + assert_output - < + spec: + description: Dummy Project for 'sloctl apply/delete' e2e tests +- apiVersion: n9/v1alpha + kind: Service + metadata: + displayName: Destroyer + name: destroyer + project: + spec: + description: Dummy Service for 'sloctl apply/delete' e2e tests +- apiVersion: n9/v1alpha + kind: Service + metadata: + displayName: Deputy Office + name: deputy-office + project: + spec: + description: Dummy Service for 'sloctl apply/delete' e2e tests diff --git a/test/inputs/apply-and-delete/project-flag-differs.yaml b/test/inputs/apply-and-delete/project-flag-differs.yaml new file mode 100644 index 0000000..60f4824 --- /dev/null +++ b/test/inputs/apply-and-delete/project-flag-differs.yaml @@ -0,0 +1,14 @@ +- apiVersion: n9/v1alpha + kind: Project + metadata: + name: death-star + spec: + description: "" +- apiVersion: n9/v1alpha + kind: Service + metadata: + displayName: Destroyer + name: destroyer + project: death-star + spec: + description: "" diff --git a/test/inputs/apply-and-delete/recursive/first-level.yaml b/test/inputs/apply-and-delete/recursive/first-level.yaml new file mode 100644 index 0000000..194c87c --- /dev/null +++ b/test/inputs/apply-and-delete/recursive/first-level.yaml @@ -0,0 +1,14 @@ +- apiVersion: n9/v1alpha + kind: Project + metadata: + name: + spec: + description: Dummy Project for 'sloctl apply/delete' e2e tests +- apiVersion: n9/v1alpha + kind: Service + metadata: + displayName: First Level + name: first-level + project: + spec: + description: Dummy Service for 'sloctl apply/delete' e2e tests diff --git a/test/inputs/apply-and-delete/recursive/nested/nested/third-level.json b/test/inputs/apply-and-delete/recursive/nested/nested/third-level.json new file mode 100644 index 0000000..fa9c61e --- /dev/null +++ b/test/inputs/apply-and-delete/recursive/nested/nested/third-level.json @@ -0,0 +1,14 @@ +[ + { + "apiVersion": "n9/v1alpha", + "kind": "Service", + "metadata": { + "displayName": "Third Level", + "name": "third-level", + "project": "" + }, + "spec": { + "description": "Dummy Service for 'sloctl apply/delete' e2e tests" + } + } +] diff --git a/test/inputs/apply-and-delete/recursive/nested/second-level.yml b/test/inputs/apply-and-delete/recursive/nested/second-level.yml new file mode 100644 index 0000000..e92cf85 --- /dev/null +++ b/test/inputs/apply-and-delete/recursive/nested/second-level.yml @@ -0,0 +1,8 @@ +- apiVersion: n9/v1alpha + kind: Service + metadata: + displayName: Second Level + name: second-level + project: + spec: + description: Dummy Service for 'sloctl apply/delete' e2e tests diff --git a/test/inputs/apply-and-delete/separate-documents.yaml b/test/inputs/apply-and-delete/separate-documents.yaml new file mode 100644 index 0000000..b1c3eb4 --- /dev/null +++ b/test/inputs/apply-and-delete/separate-documents.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: n9/v1alpha +kind: Project +metadata: + name: +spec: + description: Dummy Project for 'sloctl apply/delete' e2e tests +--- +apiVersion: n9/v1alpha +kind: Service +metadata: + displayName: Renovator + name: renovator + project: +spec: + description: Dummy Service for 'sloctl apply/delete' e2e tests +--- +apiVersion: n9/v1alpha +kind: Service +metadata: + displayName: Security Office + name: security-office + project: +spec: + description: Dummy Service for 'sloctl apply/delete' e2e tests diff --git a/test/inputs/apply-and-delete/single-object.json b/test/inputs/apply-and-delete/single-object.json new file mode 100644 index 0000000..411697d --- /dev/null +++ b/test/inputs/apply-and-delete/single-object.json @@ -0,0 +1,10 @@ +{ + "apiVersion": "n9/v1alpha", + "kind": "Project", + "metadata": { + "name": "" + }, + "spec": { + "description": "Dummy Project for 'sloctl apply/delete' e2e tests" + } +} diff --git a/test/inputs/apply-and-delete/single-object.yaml b/test/inputs/apply-and-delete/single-object.yaml new file mode 100644 index 0000000..d66dec9 --- /dev/null +++ b/test/inputs/apply-and-delete/single-object.yaml @@ -0,0 +1,6 @@ +apiVersion: n9/v1alpha +kind: Project +metadata: + name: +spec: + description: Dummy Project for 'sloctl apply/delete' e2e tests diff --git a/test/inputs/config/config.toml b/test/inputs/config/config.toml new file mode 100644 index 0000000..3e5303f --- /dev/null +++ b/test/inputs/config/config.toml @@ -0,0 +1,17 @@ +defaultContext = "minimal" +filesPromptThreshold = 40 +filesPromptEnabled = false + +[Contexts] + [Contexts.minimal] + clientId = "minimal_client_id" + clientSecret = "minimal_client_secret" + [Contexts.full] + clientId = "full_client_id" + clientSecret = "full_client_secret" + project = "default" + url = "https://apps.nobl9.com/api" + oktaOrgURL = "https://accounts.nobl9.com" + oktaAuthServer = "auseg9kiegWKEtJZC416" + disableOkta = true + timeout = "1m" diff --git a/test/inputs/delete-by-name/agent.yaml b/test/inputs/delete-by-name/agent.yaml new file mode 100644 index 0000000..171f749 --- /dev/null +++ b/test/inputs/delete-by-name/agent.yaml @@ -0,0 +1,10 @@ +apiVersion: n9/v1alpha +kind: Agent +metadata: + displayName: cloudwatch-agent + name: cloudwatch-agent + project: death-star +spec: + cloudwatch: {} + sourceOf: + - Metrics diff --git a/test/inputs/delete-by-name/alertmethod.yaml b/test/inputs/delete-by-name/alertmethod.yaml new file mode 100644 index 0000000..f65428e --- /dev/null +++ b/test/inputs/delete-by-name/alertmethod.yaml @@ -0,0 +1,15 @@ +apiVersion: n9/v1alpha +kind: AlertMethod +metadata: + name: mail-notification-genius + project: death-star +spec: + description: Dummy AlertMethod for 'sloctl get' e2e tests + email: + body: "$alert_policy_name has triggered with the following conditions:\n$alert_policy_conditions[]\n\nTime: + $timestamp\nSeverity: $severity \nProject: $project_name\nService: $service_name\nOrganization: + $organization\nLabels:\n- SLO: $slo_labels_text\n- Service: $service_labels_text\n- + Alert Policy: $alert_policy_labels_text\n\nSLO details: $slo_details_link" + subject: Your SLO $slo_name needs attention! + to: + - custodian@nobl9.com diff --git a/test/inputs/delete-by-name/alertpolicy.yaml b/test/inputs/delete-by-name/alertpolicy.yaml new file mode 100644 index 0000000..b42cfe7 --- /dev/null +++ b/test/inputs/delete-by-name/alertpolicy.yaml @@ -0,0 +1,17 @@ +apiVersion: n9/v1alpha +kind: AlertPolicy +metadata: + name: trigger-alert-immediately + project: death-star +spec: + description: Dummy AlertPolicy for 'sloctl get' e2e tests + severity: Medium + coolDown: "5m" + conditions: + - lastsFor: 2m + measurement: timeToBurnBudget + op: lt + value: 72h + alertMethods: + - metadata: + name: mail-notification-genius diff --git a/test/inputs/delete-by-name/alertsilence.yaml b/test/inputs/delete-by-name/alertsilence.yaml new file mode 100644 index 0000000..9e0cf98 --- /dev/null +++ b/test/inputs/delete-by-name/alertsilence.yaml @@ -0,0 +1,12 @@ +apiVersion: n9/v1alpha +kind: AlertSilence +metadata: + name: 5661613e-0e3d-11ed-861d-randomx2 + project: death-star +spec: + description: Dummy AlertSilence for 'sloctl get' e2e tests + slo: newrelic-rolling-timeslices-threshold + alertPolicy: + name: trigger-alert-immediately + period: + duration: 1h diff --git a/test/inputs/delete-by-name/annotation.yaml b/test/inputs/delete-by-name/annotation.yaml new file mode 100644 index 0000000..bf60676 --- /dev/null +++ b/test/inputs/delete-by-name/annotation.yaml @@ -0,0 +1,10 @@ +apiVersion: n9/v1alpha +kind: Annotation +metadata: + name: ano-tat-e + project: death-star +spec: + slo: newrelic-rolling-timeslices-threshold + description: Dummy Annotation for 'sloctl get' e2e tests + startTime: 2023-01-02T17:10:05Z + endTime: 2023-01-02T17:10:05Z diff --git a/test/inputs/delete-by-name/direct.yaml b/test/inputs/delete-by-name/direct.yaml new file mode 100644 index 0000000..1f0bc32 --- /dev/null +++ b/test/inputs/delete-by-name/direct.yaml @@ -0,0 +1,14 @@ +apiVersion: n9/v1alpha +kind: Direct +metadata: + name: newrelic-direct + displayName: Newrelic direct + project: death-star +spec: + description: This Direct is just for the e2e 'sloctl get' tests, it's not supposed to work! + sourceOf: + - Metrics + - Services + newRelic: + accountId: 1437038 + insightsQueryKey: NRIQ-123 diff --git a/test/inputs/delete-by-name/project.yaml b/test/inputs/delete-by-name/project.yaml new file mode 100644 index 0000000..057dcd6 --- /dev/null +++ b/test/inputs/delete-by-name/project.yaml @@ -0,0 +1,6 @@ +apiVersion: n9/v1alpha +kind: Project +metadata: + name: death-star +spec: + description: Dummy Project for 'sloctl get' e2e tests diff --git a/test/inputs/delete-by-name/rolebinding.yaml b/test/inputs/delete-by-name/rolebinding.yaml new file mode 100644 index 0000000..8830bbc --- /dev/null +++ b/test/inputs/delete-by-name/rolebinding.yaml @@ -0,0 +1,8 @@ +apiVersion: n9/v1alpha +kind: RoleBinding +metadata: + name: 53792c8f-41da-4658-ac4e-random +spec: + projectRef: death-star + roleRef: project-viewer + user: 00u4d8j2imVHGmBJH4x1 diff --git a/test/inputs/delete-by-name/service.yaml b/test/inputs/delete-by-name/service.yaml new file mode 100644 index 0000000..61d7300 --- /dev/null +++ b/test/inputs/delete-by-name/service.yaml @@ -0,0 +1,8 @@ +apiVersion: n9/v1alpha +kind: Service +metadata: + displayName: Destroyer + name: destroyer + project: death-star +spec: + description: Dummy Service for 'sloctl get' e2e tests diff --git a/test/inputs/delete-by-name/slo.yaml b/test/inputs/delete-by-name/slo.yaml new file mode 100644 index 0000000..0206f34 --- /dev/null +++ b/test/inputs/delete-by-name/slo.yaml @@ -0,0 +1,30 @@ +apiVersion: n9/v1alpha +kind: SLO +metadata: + name: newrelic-rolling-timeslices-threshold + project: death-star +spec: + description: This SLO is just for the e2e 'sloctl get' tests, it's not supposed to work! + service: destroyer + indicator: + metricSource: + kind: Direct + name: newrelic-direct + timeWindows: + - count: 1 + isRolling: true + unit: Hour + budgetingMethod: Timeslices + objectives: + - target: 0.99 + op: lte + rawMetric: + query: + newRelic: + nrql: SELECT average(duration) FROM Transaction TIMESERIES + displayName: stretched + timeSliceTarget: 0.99 + value: 1.2 + name: objective-1 + alertPolicies: + - trigger-alert-immediately diff --git a/test/inputs/get/agent.yaml b/test/inputs/get/agent.yaml new file mode 100644 index 0000000..d054d7e --- /dev/null +++ b/test/inputs/get/agent.yaml @@ -0,0 +1,10 @@ +- apiVersion: n9/v1alpha + kind: Agent + metadata: + displayName: cloudwatch-agent + name: cloudwatch-agent + project: death-star + spec: + cloudwatch: {} + sourceOf: + - Metrics diff --git a/test/inputs/get/alertmethods.yaml b/test/inputs/get/alertmethods.yaml new file mode 100644 index 0000000..8f5ec68 --- /dev/null +++ b/test/inputs/get/alertmethods.yaml @@ -0,0 +1,30 @@ +- apiVersion: n9/v1alpha + kind: AlertMethod + metadata: + name: mail-notification-genius + project: death-star + spec: + description: Dummy AlertMethod for 'sloctl get' e2e tests + email: + body: "$alert_policy_name has triggered with the following conditions:\n$alert_policy_conditions[]\n\nTime: + $timestamp\nSeverity: $severity \nProject: $project_name\nService: $service_name\nOrganization: + $organization\nLabels:\n- SLO: $slo_labels_text\n- Service: $service_labels_text\n- + Alert Policy: $alert_policy_labels_text\n\nSLO details: $slo_details_link" + subject: Your SLO $slo_name needs attention! + to: + - custodian@nobl9.com +- apiVersion: n9/v1alpha + kind: AlertMethod + metadata: + name: mail-notification-smart + project: death-star + spec: + description: Dummy AlertMethod for 'sloctl get' e2e tests + email: + body: "$alert_policy_name has triggered with the following conditions:\n$alert_policy_conditions[]\n\nTime: + $timestamp\nSeverity: $severity \nProject: $project_name\nService: $service_name\nOrganization: + $organization\nLabels:\n- SLO: $slo_labels_text\n- Service: $service_labels_text\n- + Alert Policy: $alert_policy_labels_text\n\nSLO details: $slo_details_link" + subject: Your SLO $slo_name needs attention! + to: + - custodian@nobl9.com diff --git a/test/inputs/get/alertpolicies.yaml b/test/inputs/get/alertpolicies.yaml new file mode 100644 index 0000000..5394f25 --- /dev/null +++ b/test/inputs/get/alertpolicies.yaml @@ -0,0 +1,34 @@ +- apiVersion: n9/v1alpha + kind: AlertPolicy + metadata: + name: trigger-alert-immediately + project: death-star + spec: + description: Dummy AlertPolicy for 'sloctl get' e2e tests + severity: Medium + coolDown: "5m" + conditions: + - lastsFor: 2m + measurement: timeToBurnBudget + op: lt + value: 72h + alertMethods: + - metadata: + name: mail-notification-genius +- apiVersion: n9/v1alpha + kind: AlertPolicy + metadata: + name: budget-will-be-burn-in-3days + project: death-star + spec: + description: Dummy AlertPolicy for 'sloctl get' e2e tests + severity: Medium + coolDown: "5m" + conditions: + - lastsFor: 10m + measurement: timeToBurnBudget + op: lt + value: 72h + alertMethods: + - metadata: + name: mail-notification-smart diff --git a/test/inputs/get/alertsilences.yaml b/test/inputs/get/alertsilences.yaml new file mode 100644 index 0000000..85518b0 --- /dev/null +++ b/test/inputs/get/alertsilences.yaml @@ -0,0 +1,24 @@ +- apiVersion: n9/v1alpha + kind: AlertSilence + metadata: + name: 5661613e-0e3d-11ed-861d-randomx1 + project: death-star + spec: + description: Dummy AlertSilence for 'sloctl get' e2e tests + slo: tokyo-server-6-latency + alertPolicy: + name: trigger-alert-immediately + period: + duration: 1h +- apiVersion: n9/v1alpha + kind: AlertSilence + metadata: + name: 5661613e-0e3d-11ed-861d-randomx2 + project: death-star + spec: + description: Dummy AlertSilence for 'sloctl get' e2e tests + slo: newrelic-rolling-timeslices-threshold + alertPolicy: + name: budget-will-be-burn-in-3days + period: + duration: 1h diff --git a/test/inputs/get/annotations.yaml b/test/inputs/get/annotations.yaml new file mode 100644 index 0000000..8f158bc --- /dev/null +++ b/test/inputs/get/annotations.yaml @@ -0,0 +1,20 @@ +- apiVersion: n9/v1alpha + kind: Annotation + metadata: + name: ano-tat-e + project: death-star + spec: + slo: newrelic-rolling-timeslices-threshold + description: Dummy Annotation for 'sloctl get' e2e tests + startTime: 2023-01-02T17:10:05Z + endTime: 2023-01-02T17:10:05Z +- apiVersion: n9/v1alpha + kind: Annotation + metadata: + name: tate-nano + project: death-star + spec: + slo: tokyo-server-6-latency + description: Dummy Annotation for 'sloctl get' e2e tests + startTime: 2023-01-02T17:20:05Z + endTime: 2023-01-02T17:30:05Z diff --git a/test/inputs/get/directs.yaml b/test/inputs/get/directs.yaml new file mode 100644 index 0000000..0ccb7cb --- /dev/null +++ b/test/inputs/get/directs.yaml @@ -0,0 +1,28 @@ +- apiVersion: n9/v1alpha + kind: Direct + metadata: + name: newrelic-direct + displayName: Newrelic direct + project: death-star + spec: + description: This Direct is just for the e2e 'sloctl get' tests, it's not supposed to work! + sourceOf: + - Metrics + - Services + newRelic: + accountId: 1437038 + insightsQueryKey: NRIQ-123 +- apiVersion: n9/v1alpha + kind: Direct + metadata: + name: splunk-observability-direct + displayName: Splunk Observability direct + project: death-star + spec: + description: This Direct is just for the e2e 'sloctl get' tests, it's not supposed to work! + sourceOf: + - Metrics + - Services + splunkObservability: + realm: us1 + accessToken: super-secret diff --git a/test/inputs/get/projects.yaml b/test/inputs/get/projects.yaml new file mode 100644 index 0000000..a4021e7 --- /dev/null +++ b/test/inputs/get/projects.yaml @@ -0,0 +1,13 @@ +- apiVersion: n9/v1alpha + kind: Project + metadata: + name: death-star + spec: + description: Dummy Project for 'sloctl get' e2e tests +- apiVersion: n9/v1alpha + kind: Project + metadata: + displayName: Hoth Base + name: hoth-base + spec: + description: Dummy Project for 'sloctl get' e2e tests diff --git a/test/inputs/get/rolebindings.yaml b/test/inputs/get/rolebindings.yaml new file mode 100644 index 0000000..2b14394 --- /dev/null +++ b/test/inputs/get/rolebindings.yaml @@ -0,0 +1,16 @@ +- apiVersion: n9/v1alpha + kind: RoleBinding + metadata: + name: 53792c8f-41da-4658-ac4e-random + spec: + projectRef: death-star + roleRef: project-viewer + user: 00u4d8j2imVHGmBJH4x1 +- apiVersion: n9/v1alpha + kind: RoleBinding + metadata: + name: 8abfed13-d997-487a-bfbd-random + spec: + projectRef: death-star + roleRef: project-viewer + user: 01u4d8j2imVHGmBJH4x1 diff --git a/test/inputs/get/services.yaml b/test/inputs/get/services.yaml new file mode 100644 index 0000000..385b743 --- /dev/null +++ b/test/inputs/get/services.yaml @@ -0,0 +1,16 @@ +- apiVersion: n9/v1alpha + kind: Service + metadata: + displayName: Destroyer + name: destroyer + project: death-star + spec: + description: Dummy Service for 'sloctl get' e2e tests +- apiVersion: n9/v1alpha + kind: Service + metadata: + displayName: Deputy Office + name: deputy-office + project: death-star + spec: + description: Dummy Service for 'sloctl get' e2e tests diff --git a/test/inputs/get/slos.yaml b/test/inputs/get/slos.yaml new file mode 100644 index 0000000..5e4fee0 --- /dev/null +++ b/test/inputs/get/slos.yaml @@ -0,0 +1,63 @@ +- apiVersion: n9/v1alpha + kind: SLO + metadata: + name: tokyo-server-6-latency + project: death-star + spec: + description: This SLO is just for the e2e 'sloctl get' tests, it's not supposed to work! + service: destroyer + indicator: + metricSource: + kind: Direct + name: splunk-observability-direct + timeWindows: + - unit: Day + count: 1 + calendar: + startTime: 2020-01-21 12:30:00 + timeZone: America/New_York + budgetingMethod: Occurrences + objectives: + - displayName: Excellent + value: 200 + name: objective-1 + target: 0.8 + op: lte + rawMetric: + query: + splunkObservability: + program: "data('demo.trans.latency', filter=filter('demo_datacenter', 'Tokyo') and filter('demo_host', 'server6')).mean().publish()" + alertPolicies: + - trigger-alert-immediately + - budget-will-be-burn-in-3days +- apiVersion: n9/v1alpha + kind: SLO + metadata: + name: newrelic-rolling-timeslices-threshold + project: death-star + spec: + description: This SLO is just for the e2e 'sloctl get' tests, it's not supposed to work! + service: destroyer + indicator: + metricSource: + kind: Direct + name: newrelic-direct + timeWindows: + - count: 1 + isRolling: true + unit: Hour + budgetingMethod: Timeslices + objectives: + - target: 0.99 + op: lte + rawMetric: + query: + newRelic: + nrql: SELECT average(duration) FROM Transaction TIMESERIES + displayName: stretched + timeSliceTarget: 0.99 + value: 1.2 + name: objective-1 + alertPolicies: + - trigger-alert-immediately + - budget-will-be-burn-in-3days diff --git a/test/outputs/config/get-contexts-verbose.txt b/test/outputs/config/get-contexts-verbose.txt new file mode 100644 index 0000000..a2bf890 --- /dev/null +++ b/test/outputs/config/get-contexts-verbose.txt @@ -0,0 +1,14 @@ +[full, minimal] +Context: full + client ID: full_client_id + client secret: fu***et + url: https://apps.nobl9.com/api + oktaOrgURL: https://accounts.nobl9.com + oktaAuthServer: auseg9kiegWKEtJZC416 + disable okta: true + timeout: 1m0s + +Context: minimal + client ID: minimal_client_id + client secret: mi***et + diff --git a/test/setup_suite.bash b/test/setup_suite.bash new file mode 100644 index 0000000..1702853 --- /dev/null +++ b/test/setup_suite.bash @@ -0,0 +1,11 @@ +setup_suite() { + load "test_helper/load" + + # General dependencies shared by all tests. + ensure_installed jq git sloctl + + export TEST_SUITE_OUTPUTS="$BATS_TEST_DIRNAME/outputs" + export TEST_SUITE_INPUTS="$BATS_TEST_DIRNAME/inputs" + + export SLOCTL_GIT_REVISION="${SLOCTL_GIT_REVISION:=undefined}" +} diff --git a/test/test_helper/load.bash b/test/test_helper/load.bash new file mode 100644 index 0000000..27078db --- /dev/null +++ b/test/test_helper/load.bash @@ -0,0 +1,235 @@ +# run_sloctl +# ========== +# +# Summary: Run the sloctl command. +# +# Usage: run_sloctl +# +# Options: +# Arguments to sloctl invocation. +# These include subcommands like 'apply', and flags. +# +# The output of sloctl is sanitized, the trailing whitespaces, +# if present, are removed for easier output validation. +run_sloctl() { + run bash -c "set -o pipefail && sloctl $* | sed 's/ *$//'" +} + +# read_files +# ========== +# +# Summary: Read the provided files and convert them into one YAML list. +# +# Usage: read_files +# +# Options: +# File paths to read from. +# +# Using -s (slurp) switch helps unify all the inputs under a single list. +# This way each input can be either flattened (if an array) or added +# to the list as is. This is particularly useful with '---' separate +# documents style. +# yq works with json as it is only a preprocessor for jq. +read_files() { + yq -sY '[ .[] | if type == "array" then .[] else . end]' "$@" +} + +# assert_applied +# ============== +# +# Summary: Fail if the expected objects were not applied. +# +# Usage: assert_applied +# +# Options: +# The expected YAML string. +assert_applied() { + _assert_objects_existence "apply" "$1" +} + +# assert_deleted +# ============== +# +# Summary: Fail if the expected objects were not deleted. +# +# Usage: assert_deleted +# +# Options: +# The expected YAML string. +assert_deleted() { + _assert_objects_existence "delete" "$1" +} + +# _assert_objects_existence +# ========================= +# +# Summary: Helper function which either asserts objects exist of not. +# +# Usage: _assert_objects_existence +# +# Options: +# Either 'apply' or 'delete'. +# List of objects to assert existence for. +# +# yq -c (compact) switch is used in order for 'read -r' to put each +# document on a separate line, which is then processed with 'read -r'. +# If the processed object is not of kind Project or RoleBinding, '-p' flag +# is added to sloctl invocation. +# 'sloctl get ${kind} ${name} -p ${project}' is used to retrieve each object +# and verify it with the respective logic: +# - apply: assert that the output contains the expected object. +# - delete: assert that the output contains 'No resources found'. +_assert_objects_existence() { + load_lib "bats-support" + + assert [ -n "$2" ] + assert [ "$(yq -r 'type' <<<"$2")" = "array" ] + + yq -c .[] <<<"$2" | while read -r object; do + name=$(yq -r .metadata.name <<<"$object") + kind=$(yq -r .kind <<<"$object") + args=("get" "${kind,,}" "$name") # Converts kind to lowercase. + if [[ "$kind" != "Project" ]] && [[ "$kind" != "RoleBinding" ]]; then + project=$(yq -r .metadata.project <<<"$object") + args+=(-p "$project") + fi + + case "$1" in + apply) + run_sloctl "${args[*]}" + # shellcheck disable=2154 + have=$(yq --sort-keys -y '[.[] | del(.status)]' <<<"$output") + want=$(yq --sort-keys -y '[ + .[] | select(.kind == "'"$kind"'") | + select(.metadata.name == "'"$name"'") | + if .metadata.project then + select(.metadata.project == "'"$project"'") + else + . + end]' <<<"$2") + assert_equal "$have" "$want" + ;; + delete) + run_sloctl "${args[*]}" + assert_output --partial "No resources found" + ;; + *) + fail "Unknown verb '$1'" + ;; + esac + done +} + +# generate_inputs +# =============== +# +# Summary: Copy test inputs into a temporary directory and modify their names. +# +# Usage: generate_inputs +# +# Options: +# Directory to generate the inputs into. +# +# Each Project gets a hash appended to its name which contains the test number, +# the current timestamp and the git commit hash. +# +# This is done in order to avoid conflicts between tests in case we ever run +# them in parallel or a cleanup after the test fails for whatever reason. +# It works for both YAML and JSON files. +generate_inputs() { + load_lib "bats-support" + + directory="$1" + test_filename=$(basename "$BATS_TEST_FILENAME" .bats) + TEST_INPUTS="$directory/$test_filename" + mkdir "$TEST_INPUTS" + + test_hash="${BATS_TEST_NUMBER}-$(date +%s)-$SLOCTL_GIT_REVISION" + TEST_PROJECT="e2e-$test_hash" + + files=$(find "$TEST_SUITE_INPUTS/$test_filename" -type f \( -iname \*.json -o -iname \*.yaml -o -iname \*.yml \)) + for file in $files; do + pipeline=' + if .kind == "Project" then + .metadata.labels = {"origin": ["sloctl-e2e-tests"]} + else + . + end' + filter=' + if type == "array" then + [.[] | '"$pipeline"' ] + else + '"$pipeline"' + end' + new_file="${file/$TEST_SUITE_INPUTS/$directory}" + mkdir -p "$(dirname "$new_file")" + sed_replace="s//$TEST_PROJECT/g" + if [[ $file =~ .*.ya?ml ]]; then + yq -Y "$filter" "$file" | sed "$sed_replace" >"$new_file" + elif [[ $file == *.json ]]; then + jq "$filter" "$file" | sed "$sed_replace" >"$new_file" + else + fail "test input file: ${file} must be either YAML or JSON" + fi + done + + export TEST_INPUTS + export TEST_PROJECT +} + +# select_object +# ============= +# +# Summary: Select an object from a given file by its original name. +# +# Usage: select_object +# +# Options: +# Object name to search for. +# File path(s) to read from. +# +# Since generate_inputs appends hashes to Project names in order to +# extract an object by its former name a regex match with jq 'test' +# function has to be performed. +select_object() { + yq '[if type == "array" then .[] else . end | + select(.metadata.name | test("^'"$1"'"))]' "$1" "$2" +} + +# ensure_installed +# ================ +# +# Summary: Ensure the provided dependencies are installed. +# +# Usage: ensure_installed +# +# Options: +# List of dependencies to check for. +# +# If 'yq' is provided as one of the dependencies, ensure it is coming from https://github.com/kislyuk/yq. +ensure_installed() { + load_lib "bats-support" + + for dep in "$@"; do + if ! command -v "$dep" >/dev/null 2>&1; then + fail "ERROR: $dep is not installed!" + fi + if [ "$dep" = "yq" ] && [ "$(yq --help | grep "kislyuk/yq")" -eq 1 ]; then + fail "ERROR: yq is not installed from https://github.com/kislyuk/yq!" + fi + done +} + +# load_lib +# ================ +# +# Summary: Load a given bats library. +# +# Usage: load_lib +# +# Options: +# Name of the library to load. +load_lib() { + local name="$1" + load "/usr/lib/bats/${name}/load.bash" +} diff --git a/test/version.bats b/test/version.bats new file mode 100644 index 0000000..3cf0bf1 --- /dev/null +++ b/test/version.bats @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# bats file_tags=unit + +# setup is run before each test. +setup() { + load "test_helper/load" + load_lib "bats-support" + load_lib "bats-assert" +} + +@test "sloctl version" { + run_sloctl version + + assert_output --regexp "sloctl/v1.0.0-PC-123-test-e2602ddc (.* .* go[0-9.])" +}